From d57b329d09446736688053ff0d203601295cc437 Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Fri, 4 Sep 2026 15:09:23 -0500 Subject: [PATCH 1/3] Pass the source member value to Condition The third argument to Condition is documented as the source member, but CreatePropertyMapFunc passed the mapped value - the value after conversion to the destination member type. For a Nullable source member and a non-nullable T destination member, null had already collapsed to default(T) by the time the condition ran, so ForAllMembers conditions saw a boxed 0/false instead of null and the common PATCH scenario was unexpressible. Pass the resolved (source-typed) value when the condition's member parameter type is assignable from it, and fall back to the mapped value otherwise. The fallback matters: a ForMember condition types its member parameters as the destination member type, so passing the source value there would emit a lossy Convert that throws on null. Fixes #4627, #2999, #3926, #4530 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH --- docs/source/Conditional-mapping.md | 42 +++++++ .../Execution/TypeMapPlanBuilder.cs | 6 +- src/UnitTests/ConditionalMapping.cs | 118 +++++++++++++++++- 3 files changed, 164 insertions(+), 2 deletions(-) diff --git a/docs/source/Conditional-mapping.md b/docs/source/Conditional-mapping.md index 1695d57bc9..abb9af31d6 100644 --- a/docs/source/Conditional-mapping.md +++ b/docs/source/Conditional-mapping.md @@ -23,6 +23,46 @@ 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 + +The source member value passed to `Condition` is the value resolved from the source object, *before* it is converted to the destination member type. This matters when the source member is a `Nullable` and the destination member is a non-nullable `T`: the condition sees `null`, not `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 +``` + +Note that `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 receives the converted value instead (`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)); +``` + +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 +98,8 @@ public interface ICondition } ``` +As with the lambda overloads, `sourceMember` is the value resolved from the source object before conversion to the destination member type -- 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/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..9ea65e7d83 100644 --- a/src/UnitTests/ConditionalMapping.cs +++ b/src/UnitTests/ConditionalMapping.cs @@ -286,4 +286,120 @@ 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); +} From 34fbd20bf43dfc54a8e314c04b7f4eba8db0ff51 Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Fri, 4 Sep 2026 15:48:37 -0500 Subject: [PATCH 2/3] Clarify when Condition receives the pre-conversion source value The docs stated the pre-conversion rule unconditionally, but a condition whose member parameter cannot hold the resolved source value falls back to the mapped value. Say so up front rather than only in the ForMember paragraph. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH --- docs/source/Conditional-mapping.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/source/Conditional-mapping.md b/docs/source/Conditional-mapping.md index abb9af31d6..503f8fe300 100644 --- a/docs/source/Conditional-mapping.md +++ b/docs/source/Conditional-mapping.md @@ -25,7 +25,7 @@ If you have a resolver, see [here](Custom-value-resolvers.html#resolvers-and-con ## Nullable source members -The source member value passed to `Condition` is the value resolved from the source object, *before* it is converted to the destination member type. This matters when the source member is a `Nullable` and the destination member is a non-nullable `T`: the condition sees `null`, not `default(T)`. +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`: @@ -48,7 +48,7 @@ mapper.Map(new Source { Count = null }, destination); // destination.Count is still 7 -- the null source member was skipped ``` -Note that `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 receives the converted value instead (`0` for `int`, `false` for `bool`). To check a nullable source member for a single member, use a `PreCondition` against the source object: +`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() @@ -98,7 +98,7 @@ public interface ICondition } ``` -As with the lambda overloads, `sourceMember` is the value resolved from the source object before conversion to the destination member type -- see [Nullable source members](#nullable-source-members). +`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: From fec985c44d16ed7b79d02a699cbe060e7d1951a5 Mon Sep 17 00:00:00 2001 From: Jimmy Bogard Date: Wed, 9 Sep 2026 14:49:53 -0500 Subject: [PATCH 3/3] Keep the condition interface's own member type on the non-generic API CreateConditionExpression types the wrapper's member parameters as TMember, which is object for the non-generic MemberConfigurationExpression. That erased the condition's real member type, so the plan builder's assignability check saw object, passed the pre-conversion source value, and an ICondition<,,int> then unboxed a boxed null Nullable and threw NullReferenceException. A source member of a different type than the condition declares would likewise have thrown InvalidCastException. Build the wrapper from the interface's own type arguments so Parameters[2].Type is the condition's actual member type and the check falls back to the mapped value, as it already does for a typed ForMember condition. Also document UseDestinationValue as the simpler way to keep the destination value when the source member is null (thanks @lbargaoanu), with the nested-object caveat that keeps it from being a general PATCH switch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH --- docs/source/Conditional-mapping.md | 9 +++ .../MemberConfigurationExpression.cs | 25 +++++++- src/UnitTests/ConditionalMapping.cs | 62 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/docs/source/Conditional-mapping.md b/docs/source/Conditional-mapping.md index 503f8fe300..4de2776967 100644 --- a/docs/source/Conditional-mapping.md +++ b/docs/source/Conditional-mapping.md @@ -55,6 +55,15 @@ 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# 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/UnitTests/ConditionalMapping.cs b/src/UnitTests/ConditionalMapping.cs index 9ea65e7d83..16758c1b71 100644 --- a/src/UnitTests/ConditionalMapping.cs +++ b/src/UnitTests/ConditionalMapping.cs @@ -403,3 +403,65 @@ class InnerDto [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); +}