Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions docs/source/Conditional-mapping.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<Source, Destination>()
.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<T>` to arrive intact. A `ForMember` condition types both member parameters as the *destination* member type, so a `Nullable<T>` 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<Source, Destination>()
.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<Source, Destination>()
.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<T>` 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to keep the destination value when mapping null, you can set UseDestinationValue, no need for conditions.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, and it's the better answer for this shape — I checked it against int? -> int: ForAllMembers(o => o.UseDestinationValue()) keeps 7 for a null source and takes 3 when set, and it works on a single ForMember too, where a condition can't see the null at all. Documented it in fec985c ahead of the PreCondition advice.

One caveat I noted with it, so nobody reads it as a blanket PATCH switch: on a member that is itself a mapped object it maps into the existing instance rather than replacing it, so the nested map still overwrites that object's own members (a source with only A set left B at 0 on the preserved instance). Scalars are where it's a clean substitute for a condition.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean :) If the missing value marker is not null, you need a condition, but other than that, it seems to me that UseDestinationValue works in all cases, inner maps included. Maybe I don't know what PATCH needs. A failing test?


```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.
Expand Down Expand Up @@ -58,6 +107,8 @@ public interface ICondition<in TSource, in TDestination, in TMember>
}
```

`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<TSource, TDestination>` is evaluated before source member resolution and does not have access to member values:

```csharp
Expand Down
25 changes: 22 additions & 3 deletions src/AutoMapper/Configuration/MemberConfigurationExpression.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace AutoMapper.Configuration;
namespace AutoMapper.Configuration;

using static System.Linq.Expressions.Expression;
using static AutoMapper.Execution.ExpressionBuilder;
Expand Down Expand Up @@ -259,8 +259,27 @@ public void ConvertUsing<TSourceMember, TDestinationMember>(IValueConverter<TSou
base.ConvertUsingCore(new(valueConverter, typeof(IValueConverter<TSourceMember, TDestinationMember>), 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<T> 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<TSource, TDestination, TMember>");
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)
{
Expand Down
6 changes: 5 additions & 1 deletion src/AutoMapper/Execution/TypeMapPlanBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +457 to +459

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use the class condition's actual member type

With the documented non-generic API CreateMap(typeof(Source), typeof(Destination)).ForMember(..., o => o.Condition(typeof(MyCondition))), CreateConditionExpression wraps the actual ICondition<Source, Destination, TMember> in a lambda whose third parameter is always object. This check therefore selects the resolved source value regardless of the condition's real member type; for example, mapping int? to int and using an ICondition<Source, Destination, int> passes a boxed null into the wrapper's cast to int and throws before Evaluate, while other differing types can throw InvalidCastException. Inspect the condition interface's actual member type, or otherwise preserve the mapped-value fallback for these runtime class conditions.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in fec985c. Reproduced it exactly as described: CreateMap(typeof(Source), typeof(Destination)).ForMember(..., o => o.Condition(typeof(ICondition<Source, Destination, int>))) over an int? source threw NullReferenceException unboxing the boxed null — passes on main, threw on the branch, so it was a regression this PR introduced.

Root cause is as you diagnosed: the non-generic MemberConfigurationExpression derives from MemberConfigurationExpression<object, object, object>, so CreateConditionExpression stamped the wrapper's member parameters as object and the plan builder's assignability check couldn't see the condition's real member type.

Took the first option — the wrapper for Condition(Type) is now built from the condition interface's own type arguments, so Parameters[2].Type is the actual member type. ICondition<,,int> now falls back to the mapped value (same as a typed ForMember condition), while ICondition<,,object> still sees the null. That also covers the InvalidCastException case you flagged for other differing types, since the check is now against the real type rather than object. Two tests added pinning both directions.

: mappedMemberVariable;
_expressions.Add(IfThen(
_configuration.ConvertReplaceParameters(memberMap.Condition,
[customSource, _destination, mappedMemberVariable, destinationMemberGetter, ContextParameter]),
[customSource, _destination, conditionSourceMember, destinationMemberGetter, ContextParameter]),
mapperExpr));
}
else if (!destinationMemberReadOnly)
Expand Down
180 changes: 179 additions & 1 deletion src/UnitTests/ConditionalMapping.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
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<Source, Destination>().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<Source, Destination, object>
{
public bool Evaluate(Source source, Destination destination, object sourceMember, object destMember, ResolutionContext context) =>
sourceMember != null;
}

protected override MapperConfiguration CreateConfiguration() => new(cfg =>
cfg.CreateMap<Source, Destination>().ForAllMembers(o => o.Condition<SourceMemberNotNull>()));

[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<Source, Destination>().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<Inner, InnerDto>();
cfg.CreateMap<Source, Destination>().ForAllMembers(o => o.Condition((source, destination, sourceMember) => sourceMember is Inner));
});

[Fact]
public void Should_receive_the_source_member() => Mapper.Map<Destination>(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<int>, 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<Source, Destination, int>
{
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<Destination>(new Source()).Value.ShouldBe(7);

[Fact]
public void Should_map_a_source_member_with_a_value() => Mapper.Map<Destination>(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<Source, Destination, object>
{
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<Destination>(new Source()).Value.ShouldBe(7);

[Fact]
public void Should_map_a_source_member_with_a_value() => Mapper.Map<Destination>(new Source { Value = 3 }).Value.ShouldBe(3);
}
Loading