Skip to content

Commit f47497b

Browse files
committed
Add MemberList enum, ForSourceMember, and DoNotValidate support (#2)
1 parent 0d7a3fa commit f47497b

8 files changed

Lines changed: 280 additions & 1 deletion

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
namespace PanoramicData.Mapper.Test;
2+
3+
public class MemberListTests
4+
{
5+
private sealed class Source
6+
{
7+
public int Id { get; set; }
8+
public string Name { get; set; } = string.Empty;
9+
public string ApiKey { get; set; } = string.Empty;
10+
}
11+
12+
private sealed class Destination
13+
{
14+
public int Id { get; set; }
15+
public string Name { get; set; } = string.Empty;
16+
}
17+
18+
private sealed class SmallSource
19+
{
20+
public int Id { get; set; }
21+
public string Name { get; set; } = string.Empty;
22+
}
23+
24+
private sealed class DestinationWithExtra
25+
{
26+
public int Id { get; set; }
27+
public string Name { get; set; } = string.Empty;
28+
public string Extra { get; set; } = string.Empty;
29+
}
30+
31+
private sealed class MemberListSourceAllMappedProfile : Profile
32+
{
33+
public MemberListSourceAllMappedProfile()
34+
{
35+
// SmallSource has Id and Name, both exist on DestinationWithExtra
36+
// Extra on destination is unmatched but MemberList.Source only validates source members
37+
CreateMap<SmallSource, DestinationWithExtra>(MemberList.Source);
38+
}
39+
}
40+
41+
private sealed class MemberListSourceUnmappedProfile : Profile
42+
{
43+
public MemberListSourceUnmappedProfile()
44+
{
45+
CreateMap<Source, Destination>(MemberList.Source);
46+
}
47+
}
48+
49+
private sealed class MemberListSourceDoNotValidateProfile : Profile
50+
{
51+
public MemberListSourceDoNotValidateProfile()
52+
{
53+
CreateMap<Source, Destination>(MemberList.Source)
54+
.ForSourceMember(source => source.ApiKey, opt => opt.DoNotValidate());
55+
}
56+
}
57+
58+
private sealed class MemberListNoneProfile : Profile
59+
{
60+
public MemberListNoneProfile()
61+
{
62+
CreateMap<Source, Destination>(MemberList.None);
63+
}
64+
}
65+
66+
[Fact]
67+
public void MemberListSource_AllSourceMembersMapped_DoesNotThrow()
68+
{
69+
var config = new MapperConfiguration(cfg =>
70+
cfg.AddProfile<MemberListSourceAllMappedProfile>());
71+
72+
// SmallSource.Id and SmallSource.Name both map to DestinationWithExtra
73+
// DestinationWithExtra.Extra is unmatched but irrelevant in MemberList.Source mode
74+
var act = () => config.AssertConfigurationIsValid();
75+
76+
act.Should().NotThrow();
77+
}
78+
79+
[Fact]
80+
public void MemberListSource_UnmappedSourceMember_Throws()
81+
{
82+
var config = new MapperConfiguration(cfg =>
83+
cfg.AddProfile<MemberListSourceUnmappedProfile>());
84+
85+
// ApiKey exists on Source but not on Destination → unmapped source member
86+
var act = () => config.AssertConfigurationIsValid();
87+
88+
act.Should().Throw<AutoMapperConfigurationException>();
89+
}
90+
91+
[Fact]
92+
public void MemberListSource_ForSourceMemberDoNotValidate_ExcludesMember()
93+
{
94+
var config = new MapperConfiguration(cfg =>
95+
cfg.AddProfile<MemberListSourceDoNotValidateProfile>());
96+
97+
var act = () => config.AssertConfigurationIsValid();
98+
99+
act.Should().NotThrow();
100+
}
101+
102+
[Fact]
103+
public void MemberListNone_SkipsValidationEntirely()
104+
{
105+
var config = new MapperConfiguration(cfg =>
106+
cfg.AddProfile<MemberListNoneProfile>());
107+
108+
var act = () => config.AssertConfigurationIsValid();
109+
110+
act.Should().NotThrow();
111+
}
112+
113+
[Fact]
114+
public void MemberListSource_MappingStillWorks()
115+
{
116+
var config = new MapperConfiguration(cfg =>
117+
cfg.AddProfile<MemberListSourceDoNotValidateProfile>());
118+
119+
var mapper = config.CreateMapper();
120+
var source = new Source { Id = 42, Name = "Test", ApiKey = "secret" };
121+
var result = mapper.Map<Destination>(source);
122+
123+
result.Id.Should().Be(42);
124+
result.Name.Should().Be("Test");
125+
}
126+
}

PanoramicData.Mapper/IMappingExpression.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,4 +114,11 @@ IMappingExpression<TSource, TDestination> Include<TDerivedSource, TDerivedDest>(
114114
/// Add a value transformer for a specific type.
115115
/// </summary>
116116
IMappingExpression<TSource, TDestination> AddTransform<TValue>(Expression<Func<TValue, TValue>> transformer);
117+
118+
/// <summary>
119+
/// Configure a specific source member for validation purposes.
120+
/// </summary>
121+
IMappingExpression<TSource, TDestination> ForSourceMember<TMember>(
122+
Expression<Func<TSource, TMember>> sourceMember,
123+
Action<ISourceMemberConfigurationExpression> memberOptions);
117124
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace PanoramicData.Mapper;
2+
3+
/// <summary>
4+
/// Configuration options for source member validation.
5+
/// </summary>
6+
public interface ISourceMemberConfigurationExpression
7+
{
8+
/// <summary>
9+
/// Exclude this source member from validation when using MemberList.Source.
10+
/// </summary>
11+
void DoNotValidate();
12+
}

PanoramicData.Mapper/Internal/TypeMap.cs

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ public sealed class TypeMap
2424

2525
internal HashSet<string> IgnoredMembers { get; } = new(StringComparer.Ordinal);
2626

27+
internal HashSet<string> IgnoredSourceMembers { get; } = new(StringComparer.Ordinal);
28+
29+
internal MemberList MemberListValidation { get; set; } = MemberList.Destination;
30+
2731
internal bool AllMembersIgnored { get; set; }
2832

2933
internal List<Delegate> BeforeMapActions { get; } = [];
@@ -587,14 +591,20 @@ private static bool IsAssignableOrConvertible(Type sourceType, Type destType)
587591

588592
/// <summary>
589593
/// Validates that all destination properties are either mapped or explicitly ignored.
594+
/// Respects the MemberList setting to determine which members to validate.
590595
/// </summary>
591596
internal List<string> GetUnmappedDestinationMembers()
592597
{
593-
if (AllMembersIgnored)
598+
if (AllMembersIgnored || MemberListValidation == MemberList.None)
594599
{
595600
return [];
596601
}
597602

603+
if (MemberListValidation == MemberList.Source)
604+
{
605+
return GetUnmappedSourceMembers();
606+
}
607+
598608
var sourceProperties = SourceType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
599609
.Where(p => p.CanRead)
600610
.ToDictionary(p => p.Name, StringComparer.Ordinal);
@@ -612,6 +622,53 @@ internal List<string> GetUnmappedDestinationMembers()
612622
return unmapped;
613623
}
614624

625+
private List<string> GetUnmappedSourceMembers()
626+
{
627+
var destProperties = DestinationType.GetProperties(BindingFlags.Public | BindingFlags.Instance)
628+
.Where(p => p.CanWrite)
629+
.ToDictionary(p => p.Name, StringComparer.Ordinal);
630+
631+
var unmapped = new List<string>();
632+
633+
foreach (var srcProp in SourceType.GetProperties(BindingFlags.Public | BindingFlags.Instance).Where(p => p.CanRead))
634+
{
635+
if (IgnoredSourceMembers.Contains(srcProp.Name))
636+
{
637+
continue;
638+
}
639+
640+
if (destProperties.ContainsKey(srcProp.Name))
641+
{
642+
continue;
643+
}
644+
645+
if (PropertyMappings.Values.Any(pm => pm.SourceExpression is not null && ExpressionReferencesProperty(pm.SourceExpression, srcProp.Name)))
646+
{
647+
continue;
648+
}
649+
650+
unmapped.Add(srcProp.Name);
651+
}
652+
653+
return unmapped;
654+
}
655+
656+
private static bool ExpressionReferencesProperty(LambdaExpression expression, string propertyName)
657+
{
658+
var body = expression.Body;
659+
if (body is UnaryExpression { Operand: MemberExpression unaryMember })
660+
{
661+
return unaryMember.Member.Name == propertyName;
662+
}
663+
664+
if (body is MemberExpression memberExpr)
665+
{
666+
return memberExpr.Member.Name == propertyName;
667+
}
668+
669+
return false;
670+
}
671+
615672
private bool IsMemberMapped(PropertyInfo destProp, Dictionary<string, PropertyInfo> sourceProperties)
616673
{
617674
if (destProp.GetCustomAttribute<IgnoreAttribute>() is not null)

PanoramicData.Mapper/MappingExpression.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,22 @@ public IMappingExpression<TSource, TDestination> AddTransform<TValue>(Expression
171171
return this;
172172
}
173173

174+
public IMappingExpression<TSource, TDestination> ForSourceMember<TMember>(
175+
Expression<Func<TSource, TMember>> sourceMember,
176+
Action<ISourceMemberConfigurationExpression> memberOptions)
177+
{
178+
var memberName = GetSourceMemberName(sourceMember);
179+
var config = new SourceMemberConfigurationExpression();
180+
memberOptions(config);
181+
182+
if (config.IsDoNotValidate)
183+
{
184+
typeMap.IgnoredSourceMembers.Add(memberName);
185+
}
186+
187+
return this;
188+
}
189+
174190
private static string GetMemberName<TMember>(Expression<Func<TDestination, TMember>> expression)
175191
{
176192
if (expression.Body is MemberExpression memberExpression)
@@ -186,6 +202,21 @@ private static string GetMemberName<TMember>(Expression<Func<TDestination, TMemb
186202
throw new ArgumentException($"Expression '{expression}' does not refer to a property or field.");
187203
}
188204

205+
private static string GetSourceMemberName<TMember>(Expression<Func<TSource, TMember>> expression)
206+
{
207+
if (expression.Body is MemberExpression memberExpression)
208+
{
209+
return memberExpression.Member.Name;
210+
}
211+
212+
if (expression.Body is UnaryExpression { Operand: MemberExpression unaryMember })
213+
{
214+
return unaryMember.Member.Name;
215+
}
216+
217+
throw new ArgumentException($"Expression '{expression}' does not refer to a property or field.");
218+
}
219+
189220
private static string[] GetPathSegments<TMember>(Expression<Func<TDestination, TMember>> expression)
190221
{
191222
var segments = new List<string>();

PanoramicData.Mapper/MemberList.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace PanoramicData.Mapper;
2+
3+
/// <summary>
4+
/// Specifies which member list to validate during AssertConfigurationIsValid.
5+
/// </summary>
6+
public enum MemberList
7+
{
8+
/// <summary>
9+
/// Validate that all writable destination members are mapped (default).
10+
/// </summary>
11+
Destination = 0,
12+
13+
/// <summary>
14+
/// Validate that all readable source members are mapped to a destination member.
15+
/// </summary>
16+
Source = 1,
17+
18+
/// <summary>
19+
/// Skip member validation entirely for this map.
20+
/// </summary>
21+
None = 2
22+
}

PanoramicData.Mapper/Profile.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,19 @@ protected IMappingExpression<TSource, TDestination> CreateMap<TSource, TDestinat
2121
return new MappingExpression<TSource, TDestination>(typeMap, RegisterTypeMap);
2222
}
2323

24+
/// <summary>
25+
/// Create a mapping between the source and destination types with a specific member list for validation.
26+
/// </summary>
27+
protected IMappingExpression<TSource, TDestination> CreateMap<TSource, TDestination>(MemberList memberList)
28+
{
29+
var typeMap = new TypeMap(typeof(TSource), typeof(TDestination))
30+
{
31+
MemberListValidation = memberList
32+
};
33+
TypeMaps.Add(typeMap);
34+
return new MappingExpression<TSource, TDestination>(typeMap, RegisterTypeMap);
35+
}
36+
2437
/// <summary>
2538
/// Create a mapping between open generic types (e.g., typeof(Source&lt;&gt;), typeof(Dest&lt;&gt;)).
2639
/// </summary>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
namespace PanoramicData.Mapper;
2+
3+
/// <summary>
4+
/// Implementation of source member configuration that tracks DoNotValidate calls.
5+
/// </summary>
6+
internal sealed class SourceMemberConfigurationExpression : ISourceMemberConfigurationExpression
7+
{
8+
internal bool IsDoNotValidate { get; private set; }
9+
10+
public void DoNotValidate() => IsDoNotValidate = true;
11+
}

0 commit comments

Comments
 (0)