diff --git a/src/Riok.Mapperly.Abstractions/MapperAttribute.cs b/src/Riok.Mapperly.Abstractions/MapperAttribute.cs index f0e1190379..1b01daca76 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 ) + /// 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..b814807c43 100644 --- a/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs +++ b/src/Riok.Mapperly/Descriptors/MappingBodyBuilders/BuilderContext/IgnoredMembersBuilder.cs @@ -28,6 +28,12 @@ .. GetIgnoredAtMemberMembers(ctx, sourceTarget), .. GetIgnoredObsoleteMembers(ctx, sourceTarget), ]; + if (ctx.Configuration.Mapper.OnlyExplicitMappedMembers) + { + var explicitlyMappedMembers = ctx.Configuration.Members.GetMembersWithExplicitConfigurations(sourceTarget).ToHashSet(); + ignoredMembers.UnionWith(allMembers.Where(m => !explicitlyMappedMembers.Contains(m))); + } + 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..84d42fc0c9 100644 --- a/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs +++ b/test/Riok.Mapperly.Tests/Mapping/ObjectPropertyIgnoreTest.cs @@ -168,4 +168,96 @@ public void WithNestedIgnoredSourceAndTargetPropertyShouldDiagnostic() """ ); } + + [Fact] + 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; } }", + "class B { public int Value1 { get; set; } public int Value2 { get; set; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + return target; + """ + ); + } + + [Fact] + public void OnlyExplicitMappedMembersWithExplicitMapping() + { + // Only the explicitly declared [MapProperty] member is mapped; Value2 is ignored even though it matches by name + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[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; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value1 = source.Value1; + return target; + """ + ); + } + + [Fact] + public void OnlyExplicitMappedMembersWithMultipleExplicitMappings() + { + // Only the two explicitly declared [MapProperty] members are mapped; unmatched/extra members are silently ignored + var source = TestSourceBuilder.MapperWithBodyAndTypes( + "[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; } }" + ); + + TestHelper + .GenerateMapper(source) + .Should() + .HaveSingleMethodBody( + """ + var target = new global::B(); + target.Value1 = source.Value1; + target.Value2 = source.Value2; + return target; + """ + ); + } + + [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; + """ + ); + } } 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";