From 57703347f87a3af82bafaec533b3569cab09437b Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 17 Sep 2026 09:54:12 +0200 Subject: [PATCH] fix: evaluate direct nullable updater sources once Co-authored-by: Codex --- .../LiteMapperGenerator.cs | 8 +-- .../NestedUpdateMappingTests.cs | 64 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 94f55ec..dfa74cd 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -743,15 +743,15 @@ private static MappingModel CreateUpdateMappingModel(IMethodSymbol method, Compi var escapedSourceName = EscapeIdentifier(source.Name); var sourcePath = selected?.Expression ?? match.Member.Name; var sourceMayBeNull = !rootArgument && (selected != null ? SourcePathMayBeNull(selected) : SourceMayBeNull(match.Member)); - var throwCaptureName = sourceMayBeNull && selected != null && !options.IgnoreNullSourceMembers && + var throwCaptureName = sourceMayBeNull && !options.IgnoreNullSourceMembers && !IsMaybeNull(updater.Parameters[0].Type) && options.NullableMismatch != NullableMismatchPolicyError - ? CreateExpressionCaptureName(method, targetMember.Name, selected.Expression) + ? CreateExpressionCaptureName(method, targetMember.Name, selected?.Expression ?? match.Member.Name) : null; var argument = rootArgument ? escapedSourceName : selected != null ? sourcePathCaptureName ?? throwCaptureName ?? BuildNullSafeSourcePathExpression(source.Name, selected) - : escapedSourceName + "." + EscapeIdentifier(match.Member.Name); + : throwCaptureName ?? escapedSourceName + "." + EscapeIdentifier(match.Member.Name); string? updaterGuard = null; PreconditionModel? updaterPrecondition = null; if (sourceMayBeNull) @@ -769,7 +769,7 @@ private static MappingModel CreateUpdateMappingModel(IMethodSymbol method, Compi } var nullCheck = throwCaptureName != null - ? "!(" + BuildNullSafeSourcePathExpression(source.Name, selected!) + " is { } " + throwCaptureName + ")" + ? "!(" + (selected != null ? BuildNullSafeSourcePathExpression(source.Name, selected) : escapedSourceName + "." + EscapeIdentifier(match.Member.Name)) + " is { } " + throwCaptureName + ")" : selected == null ? argument + " == null" : BuildNullCheck(source.Name, selected); if (nullCheck != null) { diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/NestedUpdateMappingTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/NestedUpdateMappingTests.cs index 321de4d..3921d90 100644 --- a/tests/Mammoth.LiteMapper.Generator.Tests/NestedUpdateMappingTests.cs +++ b/tests/Mammoth.LiteMapper.Generator.Tests/NestedUpdateMappingTests.cs @@ -152,6 +152,70 @@ public void ThrowPolicyReportsTheNullNestedUpdaterSourcePath() StringAssert.Contains((string)Execute(result)!, "Child", "The runtime mismatch must identify the null member path."); } + [TestMethod] + public void ThrowPolicyEvaluatesDirectNullableNestedUpdaterSourceOnce() + { + var result = Run(@" +#nullable enable +using System; +using Mammoth.LiteMapper; + +[LiteMapper(NullableMismatch = NullableMismatchPolicy.Throw)] +public static partial class Mapper +{ + public static int UpdaterCalls; + + public static partial void Apply(Source source, Target target); + + [DefaultMapping] + private static void ApplyChild(ChildSource source, ChildTarget target) + { + UpdaterCalls++; + target.Value = source.Value; + } +} + +public sealed class Source +{ + private readonly ChildSource? child; + public static int ChildReads; + + public Source(ChildSource? child) { this.child = child; } + public ChildSource? Child { get { ChildReads++; return child; } } +} + +public sealed class ChildSource { public int Value { get; set; } } +public sealed class ChildTarget { public int Value { get; set; } } +public sealed class Target { public ChildTarget Child { get; } = new ChildTarget(); } + +public static class Probe +{ + public static bool Run() + { + Source.ChildReads = Mapper.UpdaterCalls = 0; + var target = new Target(); + Mapper.Apply(new Source(new ChildSource { Value = 3 }), target); + var present = target.Child.Value == 3 && Source.ChildReads == 1 && Mapper.UpdaterCalls == 1; + + Source.ChildReads = Mapper.UpdaterCalls = 0; + try + { + Mapper.Apply(new Source(null), target); + return false; + } + catch (InvalidOperationException exception) + { + return present && exception.Message.Contains(""Child"", StringComparison.Ordinal) && + Source.ChildReads == 1 && Mapper.UpdaterCalls == 0; + } + } +} +"); + + Assert.AreEqual(true, Execute(result), + "A direct nullable source passed to a Throw-policy nested updater must be evaluated once for present and null values."); + } + [TestMethod] public void ReturningUpdaterCreatesAndAssignsANullableWritableChild() {