Pass the source member value to Condition - #4655
Conversation
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<T> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH
There was a problem hiding this comment.
🟡 Changes recommended
The new documentation text overstates that Condition always receives the pre-conversion resolved source member, but the implementation intentionally falls back to the mapped (destination-typed) value in some cases.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes AutoMapper’s conditional mapping contract by ensuring the sourceMember argument passed to Condition is (when type-compatible) the resolved source member value before conversion to the destination member type, which restores correct null behavior for Nullable<T> → non-nullable T PATCH-style scenarios.
Changes:
- Update
TypeMapPlanBuilder.CreatePropertyMapFuncto pass the resolved source member intoConditionwhen the condition’ssourceMemberparameter type can accept it, otherwise fall back to the mapped (destination-typed) value. - Add unit tests covering nullable source members, class-based conditions, destination-typed conditions (fallback behavior), and differing source/destination member types.
- Extend conditional mapping docs with guidance for nullable source members, including the recommended
PreConditionworkaround for destination-typedForMemberconditions.
File summaries
| File | Description |
|---|---|
| src/AutoMapper/Execution/TypeMapPlanBuilder.cs | Adjusts which member value is supplied to Condition to preserve pre-conversion source values when compatible. |
| src/UnitTests/ConditionalMapping.cs | Adds regression tests to pin the corrected behavior and the necessary fallback behavior. |
| docs/source/Conditional-mapping.md | Documents nullable-source-member conditional mapping semantics and recommended patterns. |
Review details
Suppressed comments (1)
docs/source/Conditional-mapping.md:101
- This line repeats the same “always pre-conversion” claim, but destination-typed conditions can still receive the mapped (converted) member value when the resolved source value isn’t assignable to the condition parameter type. Reword to match the actual selection logic.
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).
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
|
||
| ## 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<T>` and the destination member is a non-nullable `T`: the condition sees `null`, not `default(T)`. |
There was a problem hiding this comment.
Fixed in 34fbd20. Both spots now state the actual selection rule: the pre-conversion source value when the condition's member parameter can hold it, the converted value otherwise.
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 34fbd20bf4
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| var conditionMemberType = memberMap.Condition.Parameters[2].Type; | ||
| var conditionSourceMember = conditionMemberType.IsAssignableFrom(resolvedValueVariable.Type) | ||
| ? (Expression)resolvedValueVariable |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
| .ForMember(dest => dest.Count, opt => opt.PreCondition(src => src.Count != null)); | ||
| ``` | ||
|
|
||
| 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: |
There was a problem hiding this comment.
If you want to keep the destination value when mapping null, you can set UseDestinationValue, no need for conditions.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
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<int> 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH
Closes #4627. Also addresses #2999, #3926 and #4530.
The bug
CreatePropertyMapFuncpassedmappedMemberVariable— the value after conversion to the destination member type — as the third argument toCondition. That argument is documented as the source member (IMemberConfigurationExpression.Condition: "against the source, destination, source and destination members"), and the fourth argument is already the destination getter.For a
Nullable<T>source member and a non-nullableTdestination member, null had already collapsed todefault(T)by the time the condition ran.ForAllMemberstypes the member parameter asobject, so the condition boxed the0/falserather than receiving a null — which makes the "only assign what the caller supplied" PATCH scenario unexpressible. That's what's been reported repeatedly since 2018.Confirmed on
main, source{ Count = null }mapped over a destination withCount = 7:ForAllMembers(o => o.Condition((s,d,sm) => sm != null))ForMember(d => d.Count, o => o.PreCondition(s => s.Count != null))The fix
Use the source-typed
resolvedValueVariablewhen the condition's member parameter type is assignable from it, and fall back to the mapped value otherwise:The fallback is load-bearing. A
ForMembercondition types both member parameters as the destination member type (TMember), so passing anint?there would emitConvert(int?, int)and throwInvalidOperationExceptionon null. Those conditions keep today's behavior, andWhen_using_a_condition_typed_as_the_destination_memberguards it.Behavior change
For
ForAllMembers(or any condition whose member parameter isobjector a base type) where the source and destination member types differ, the condition now receives the source member instead of the mapped destination member.When_using_a_condition_for_all_members_with_different_source_and_destination_member_typespins this. It matches the documented contract, but it is observable. Targeting 16.3.No public API surface change, so no ApiCompat baseline update.
Not changed
dest.Int = src.NullableInt ?? default(int)when there is no condition. A name match always produces an assignment and a non-nullable destination member cannot represent absence — that part of #2918 is inherent, and is now written down in the docs instead.Docs
New "Nullable source members" section in
Conditional-mapping.mdcovering theForAllMemberspattern, whyForMemberconditions can't see null and should usePreCondition, and the?? default(T)rule with a pointer to null substitution. The documentation gap is arguably the bigger half of #4627 — five issues over eight years never surfaced thePreConditionworkaround. The blanket-PATCH workaround (cfg.Internal().ForAllPropertyMaps(...)closing overpm.SourceMember) is deliberately not documented here; whether it should be promoted out ofAutoMapper.Internalis #4656.Testing
Full unit suite passes (1226). The three new behavior tests fail without the
TypeMapPlanBuilderchange and pass with it. Integration tests were not run locally (they need SQL Server via Testcontainers) — leaving those to CI.🤖 Generated with Claude Code
https://claude.ai/code/session_01Wm2m61jXrAs4FheXR5TufH