diff --git a/docs/source/Conditional-mapping.md b/docs/source/Conditional-mapping.md index 1695d57bc9..4de2776967 100644 --- a/docs/source/Conditional-mapping.md +++ b/docs/source/Conditional-mapping.md @@ -23,6 +23,55 @@ var configuration = new MapperConfiguration(cfg => { ``` If you have a resolver, see [here](Custom-value-resolvers.html#resolvers-and-conditions) for a concrete example. +## Nullable source members + +When the condition's source member parameter can hold it, `Condition` receives the value resolved from the source object *before* it is converted to the destination member type; otherwise it receives the converted value. This matters when the source member is a `Nullable` and the destination member is a non-nullable `T`, because the parameter type decides whether the condition sees `null` or `default(T)`. + +This makes the common PATCH scenario -- "only assign members the caller actually supplied" -- expressible with `ForAllMembers`: + +```c# +class Source { + public int? Count { get; set; } +} + +class Destination { + public int Count { get; set; } +} + +var configuration = new MapperConfiguration(cfg => { + cfg.CreateMap() + .ForAllMembers(opt => opt.Condition((src, dest, srcMember) => srcMember != null)); +}, loggerFactory); + +var destination = new Destination { Count = 7 }; +mapper.Map(new Source { Count = null }, destination); +// destination.Count is still 7 -- the null source member was skipped +``` + +`ForAllMembers` types the source member parameter as `object`, which is what allows a `Nullable` to arrive intact. A `ForMember` condition types both member parameters as the *destination* member type, so a `Nullable` source cannot be represented there and the condition falls back to the converted value (`0` for `int`, `false` for `bool`). To check a nullable source member for a single member, use a `PreCondition` against the source object: + +```c# +cfg.CreateMap() + .ForMember(dest => dest.Count, opt => opt.PreCondition(src => src.Count != null)); +``` + +For this particular shape -- keep whatever the destination already holds when the source member is null -- `UseDestinationValue` does the same job without a condition at all, and works for a `ForMember` too (where a condition can't see the null): + +```c# +cfg.CreateMap() + .ForAllMembers(opt => opt.UseDestinationValue()); +``` + +Reach for it when the members are scalars. It is not a general PATCH switch: on a member that is itself a mapped object, `UseDestinationValue` maps *into* the existing destination instance rather than replacing it, so the nested map still overwrites that object's own members with the source's (unset ones landing as defaults). Members you want left alone entirely still need a condition. + +Without a condition, a null `Nullable` source member is always assigned as `default(T)`. AutoMapper does not decide to map zero -- a name match always produces an assignment, and a non-nullable destination member has no way to represent the absence of a value. Roughly: + +```c# +dest.Count = src.Count ?? default(int); +``` + +If you need a value other than `default(T)`, see [Null substitution](Null-substitution.html). + ## Preconditions Similarly, there is a PreCondition method. The difference is that it runs sooner in the mapping process, before the source value is resolved (think MapFrom). So the precondition is called, then we decide which will be the source of the mapping (resolving), then the condition is called and finally the destination value is assigned. @@ -58,6 +107,8 @@ public interface ICondition } ``` +`sourceMember` follows the same rule as the lambda overloads: the pre-conversion source value when `TMember` can hold it, the converted value otherwise -- see [Nullable source members](#nullable-source-members). + `IPreCondition` is evaluated before source member resolution and does not have access to member values: ```csharp diff --git a/src/AutoMapper/Configuration/MemberConfigurationExpression.cs b/src/AutoMapper/Configuration/MemberConfigurationExpression.cs index 32bdf43dc7..98a15e81bf 100644 --- a/src/AutoMapper/Configuration/MemberConfigurationExpression.cs +++ b/src/AutoMapper/Configuration/MemberConfigurationExpression.cs @@ -1,4 +1,4 @@ -namespace AutoMapper.Configuration; +namespace AutoMapper.Configuration; using static System.Linq.Expressions.Expression; using static AutoMapper.Execution.ExpressionBuilder; @@ -259,8 +259,27 @@ public void ConvertUsing(IValueConverter), sourceMemberName)); public void Condition(Type conditionType) { - var expr = CreateConditionExpression(conditionType); - ConditionCore(expr); + // Build the wrapper with the condition interface's OWN type arguments rather than going through + // CreateConditionExpression, which types the member parameters as TMember -- object on this + // non-generic path, erasing the condition's real member type. The plan builder reads + // Condition.Parameters[2].Type to decide whether the pre-conversion source value fits the + // condition; an erased object parameter would let a Nullable reach an ICondition<,,T> and + // fail unboxing it. Declaring the real types keeps that decision honest, and drops the casts + // CreateConditionExpression needed to bridge TMember to the interface. + var interfaceType = conditionType.GetGenericInterface(typeof(ICondition<,,>)) ?? + throw new InvalidOperationException($"Type '{conditionType.Name}' does not implement ICondition"); + var interfaceArgs = interfaceType.GenericTypeArguments; + var srcParam = Parameter(interfaceArgs[0]); + var destParam = Parameter(interfaceArgs[1]); + var srcMemberParam = Parameter(interfaceArgs[2]); + var destMemberParam = Parameter(interfaceArgs[2]); + var ctxParam = Parameter(typeof(ResolutionContext)); + var callExpression = Call( + Convert(ServiceLocator(conditionType), interfaceType), + interfaceType.GetMethod("Evaluate"), + srcParam, destParam, srcMemberParam, destMemberParam, ctxParam); + var expr = Lambda(callExpression, srcParam, destParam, srcMemberParam, destMemberParam, ctxParam); + PropertyMapActions.Add(pm => pm.Condition = expr); } public void PreCondition(Type preConditionType) { diff --git a/src/AutoMapper/Execution/TypeMapPlanBuilder.cs b/src/AutoMapper/Execution/TypeMapPlanBuilder.cs index 6f2f99ae6b..9c53e8916b 100644 --- a/src/AutoMapper/Execution/TypeMapPlanBuilder.cs +++ b/src/AutoMapper/Execution/TypeMapPlanBuilder.cs @@ -454,9 +454,13 @@ private Expression CreatePropertyMapFunc(MemberMap memberMap, Expression destina : Assign(destinationMemberAccess, mappedMemberVariable); if (memberMap.Condition != null) { + var conditionMemberType = memberMap.Condition.Parameters[2].Type; + var conditionSourceMember = conditionMemberType.IsAssignableFrom(resolvedValueVariable.Type) + ? (Expression)resolvedValueVariable + : mappedMemberVariable; _expressions.Add(IfThen( _configuration.ConvertReplaceParameters(memberMap.Condition, - [customSource, _destination, mappedMemberVariable, destinationMemberGetter, ContextParameter]), + [customSource, _destination, conditionSourceMember, destinationMemberGetter, ContextParameter]), mapperExpr)); } else if (!destinationMemberReadOnly) diff --git a/src/UnitTests/ConditionalMapping.cs b/src/UnitTests/ConditionalMapping.cs index c0db7c0e4f..16758c1b71 100644 --- a/src/UnitTests/ConditionalMapping.cs +++ b/src/UnitTests/ConditionalMapping.cs @@ -286,4 +286,182 @@ public void Should_forward_and_reverse_map_an_inaccessible_source_property_even_ { _source.Respect.ShouldBe("R-E-S-P-E-C-T"); // justification: if the mapping works one way, it should work in reverse } -} \ No newline at end of file +} +public class When_using_a_condition_with_a_nullable_source_member_and_a_value_type_destination : AutoMapperSpecBase +{ + class Source + { + public int? Value { get; set; } + public bool? Toggle { get; set; } + } + + class Destination + { + public int Value { get; set; } = 7; + public bool Toggle { get; set; } = true; + } + + protected override MapperConfiguration CreateConfiguration() => new(cfg => + cfg.CreateMap().ForAllMembers(o => o.Condition((source, destination, sourceMember) => sourceMember != null))); + + [Fact] + public void Should_not_map_a_null_source_member() + { + var destination = Mapper.Map(new Source(), new Destination()); + destination.Value.ShouldBe(7); + destination.Toggle.ShouldBeTrue(); + } + + [Fact] + public void Should_map_a_source_member_with_a_value() + { + var destination = Mapper.Map(new Source { Value = 3, Toggle = false }, new Destination()); + destination.Value.ShouldBe(3); + destination.Toggle.ShouldBeFalse(); + } +} + +public class When_using_a_class_based_condition_with_a_nullable_source_member : AutoMapperSpecBase +{ + class Source + { + public int? Value { get; set; } + } + + class Destination + { + public int Value { get; set; } = 7; + } + + class SourceMemberNotNull : ICondition + { + public bool Evaluate(Source source, Destination destination, object sourceMember, object destMember, ResolutionContext context) => + sourceMember != null; + } + + protected override MapperConfiguration CreateConfiguration() => new(cfg => + cfg.CreateMap().ForAllMembers(o => o.Condition())); + + [Fact] + public void Should_not_map_a_null_source_member() => Mapper.Map(new Source(), new Destination()).Value.ShouldBe(7); + + [Fact] + public void Should_map_a_source_member_with_a_value() => Mapper.Map(new Source { Value = 3 }, new Destination()).Value.ShouldBe(3); +} + +public class When_using_a_condition_typed_as_the_destination_member : AutoMapperSpecBase +{ + class Source + { + public int? Value { get; set; } + } + + class Destination + { + public int Value { get; set; } = 7; + } + + protected override MapperConfiguration CreateConfiguration() => new(cfg => + cfg.CreateMap().ForMember(d => d.Value, o => o.Condition((source, destination, sourceMember) => sourceMember > 0))); + + [Fact] + public void Should_receive_the_mapped_value_because_the_source_value_does_not_fit_the_condition_member_type() => + Mapper.Map(new Source(), new Destination()).Value.ShouldBe(7); + + [Fact] + public void Should_map_a_source_member_with_a_value() => Mapper.Map(new Source { Value = 3 }, new Destination()).Value.ShouldBe(3); +} + +public class When_using_a_condition_for_all_members_with_different_source_and_destination_member_types : AutoMapperSpecBase +{ + class Source + { + public Inner Value { get; set; } + } + + class Destination + { + public InnerDto Value { get; set; } + } + + class Inner + { + public int Number { get; set; } + } + + class InnerDto + { + public int Number { get; set; } + } + + protected override MapperConfiguration CreateConfiguration() => new(cfg => + { + cfg.CreateMap(); + cfg.CreateMap().ForAllMembers(o => o.Condition((source, destination, sourceMember) => sourceMember is Inner)); + }); + + [Fact] + public void Should_receive_the_source_member() => Mapper.Map(new Source { Value = new Inner { Number = 3 } }).Value.Number.ShouldBe(3); +} + +public class When_using_a_runtime_class_condition_typed_as_the_destination_member : AutoMapperSpecBase +{ + class Source + { + public int? Value { get; set; } + } + + class Destination + { + public int Value { get; set; } = 7; + } + + // Declares int, not object: the condition can't hold a null Nullable, so it must receive the + // mapped value. The non-generic API erases TMember to object, so the wrapper has to carry the + // interface's own member type or this unboxes a boxed null and throws. + class PositiveValue : ICondition + { + public bool Evaluate(Source source, Destination destination, int sourceMember, int destMember, ResolutionContext context) => + sourceMember > 0; + } + + protected override MapperConfiguration CreateConfiguration() => new(cfg => + cfg.CreateMap(typeof(Source), typeof(Destination)) + .ForMember(nameof(Destination.Value), o => o.Condition(typeof(PositiveValue)))); + + [Fact] + public void Should_receive_the_mapped_value_for_a_null_source_member() => + Mapper.Map(new Source()).Value.ShouldBe(7); + + [Fact] + public void Should_map_a_source_member_with_a_value() => Mapper.Map(new Source { Value = 3 }).Value.ShouldBe(3); +} + +public class When_using_a_runtime_class_condition_typed_as_object : AutoMapperSpecBase +{ + class Source + { + public int? Value { get; set; } + } + + class Destination + { + public int Value { get; set; } = 7; + } + + class SourceMemberNotNull : ICondition + { + public bool Evaluate(Source source, Destination destination, object sourceMember, object destMember, ResolutionContext context) => + sourceMember != null; + } + + protected override MapperConfiguration CreateConfiguration() => new(cfg => + cfg.CreateMap(typeof(Source), typeof(Destination)) + .ForMember(nameof(Destination.Value), o => o.Condition(typeof(SourceMemberNotNull)))); + + [Fact] + public void Should_see_the_null_source_member() => Mapper.Map(new Source()).Value.ShouldBe(7); + + [Fact] + public void Should_map_a_source_member_with_a_value() => Mapper.Map(new Source { Value = 3 }).Value.ShouldBe(3); +}