Skip to content

Pass the source member value to Condition - #4655

Merged
jbogard merged 3 commits into
mainfrom
fix/condition-source-member-nullable
Sep 9, 2026
Merged

jbogard merged 3 commits into
mainfrom
fix/condition-source-member-nullable

Conversation

@jbogard

@jbogard jbogard commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Closes #4627. Also addresses #2999, #3926 and #4530.

The bug

CreatePropertyMapFunc passed mappedMemberVariable — the value after conversion to the destination member type — as the third argument to Condition. 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-nullable T destination member, null had already collapsed to default(T) by the time the condition ran. ForAllMembers types the member parameter as object, so the condition boxed the 0/false rather 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 with Count = 7:

config before after
ForAllMembers(o => o.Condition((s,d,sm) => sm != null)) 0 7
ForMember(d => d.Count, o => o.PreCondition(s => s.Count != null)) 7 7

The fix

Use the source-typed resolvedValueVariable when the condition's member parameter type is assignable from it, and fall back to the mapped value otherwise:

var conditionMemberType = memberMap.Condition.Parameters[2].Type;
var conditionSourceMember = conditionMemberType.IsAssignableFrom(resolvedValueVariable.Type)
    ? (Expression)resolvedValueVariable
    : mappedMemberVariable;

The fallback is load-bearing. A ForMember condition types both member parameters as the destination member type (TMember), so passing an int? there would emit Convert(int?, int) and throw InvalidOperationException on null. Those conditions keep today's behavior, and When_using_a_condition_typed_as_the_destination_member guards it.

Behavior change

For ForAllMembers (or any condition whose member parameter is object or 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_types pins 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.md covering the ForAllMembers pattern, why ForMember conditions can't see null and should use PreCondition, 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 the PreCondition workaround. The blanket-PATCH workaround (cfg.Internal().ForAllPropertyMaps(...) closing over pm.SourceMember) is deliberately not documented here; whether it should be promoted out of AutoMapper.Internal is #4656.

Testing

Full unit suite passes (1226). The three new behavior tests fail without the TypeMapPlanBuilder change 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

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

Copilot AI left a comment

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.

🟡 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.CreatePropertyMapFunc to pass the resolved source member into Condition when the condition’s sourceMember parameter 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 PreCondition workaround for destination-typed ForMember conditions.
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.

Comment thread docs/source/Conditional-mapping.md Outdated

## 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)`.

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.

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.

@jbogard jbogard added this to the 16.3.0 milestone Sep 4, 2026
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
@jbogard

jbogard commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 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".

Comment on lines +457 to +459
var conditionMemberType = memberMap.Condition.Parameters[2].Type;
var conditionSourceMember = conditionMemberType.IsAssignableFrom(resolvedValueVariable.Type)
? (Expression)resolvedValueVariable

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.

.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:

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?

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
@jbogard
jbogard merged commit 6e8697b into main Sep 9, 2026
6 checks passed
@jbogard
jbogard deleted the fix/condition-source-member-nullable branch September 9, 2026 19:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Automapper keeps closing the issues related to Nullable source to Non-Nullable destination mapping

3 participants