From e0ea46ce634273786d5fad0e2fc57961e6d17d6b Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Fri, 9 Oct 2026 14:07:27 +0200 Subject: [PATCH] fix: reuse captured sources for patch nested updaters Use the guarded direct member value once and preserve nullable value-type overload binding. Add regression, sample, package-consumer, and documentation coverage. Fixes #29. Co-authored-by: Codex --- CHANGELOG.md | 2 + DECISIONS.md | 7 + IMPLEMENTATION_PLAN.md | 8 + STATUS.md | 10 + docs/USAGE.md | 2 + .../Program.cs | 39 +++ .../references/mapping-rules.md | 2 +- .../LiteMapperGenerator.cs | 7 +- .../PatchNestedUpdaterCaptureTests.cs | 230 ++++++++++++++++++ .../ConsumerMatrixTests.cs | 39 ++- .../Milestone14PackagingAndAotTests.cs | 37 +++ 11 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 tests/Mammoth.LiteMapper.Generator.Tests/PatchNestedUpdaterCaptureTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be9095..1c25cc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ ### Fixed +- Issue #29: patch-mode nested updaters consume the captured direct source value once, preserving null skips, overload selection, and sequential update behavior. [#29](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/29) + - Issue #11: existing-target mappings with `NullableMismatch.Throw` now evaluate and enforce nullable preconditions in deterministic per-member order, preserving earlier assignments when a later member fails. [#11](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/11) - Issue #12: declared cycle tracking now identifies mappings by symbol identity instead of method name, preventing overload false positives, invalid tracker arguments, and unnecessary tracking state. [#12](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/12) - Issue #13: direct nullable sources passed to nested updaters are evaluated once, preventing duplicate source getter or path evaluation. [#13](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/13) diff --git a/DECISIONS.md b/DECISIONS.md index bc6b299..9170982 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Decision Log +## Issue #29: reuse direct patch captures without changing overload binding (2026-10-09) + +- Context: patch planning creates a single-evaluation capture for direct nullable members, but nested updater invocation used the original getter. Present values were read twice; changing getters could supply a different value or null after the guard. +- Decision: prefer the existing patch capture before the existing Throw capture and direct expression. For direct nullable value types, cast the unwrapped pattern capture back to its original source type, preserving the updater overload that was selected from that type. Configured-path handling and per-member emission order remain unchanged. +- Contract: sections 9.4, 11.2-11.4, 14.4, 16.1, and 16.3 already define selection, single evaluation, null skipping, and sequential failure behavior. No specification semantics or public API change is required. +- Evidence: 34 initial generator cases on unchanged production yielded 17 failures and 17 controls, zero skips; the normally compiled Basic example independently threw NullReferenceException. A nullable-struct overload control passed on unchanged production and failed the naive one-line capture candidate (99 instead of the selected updater's 7), motivating the type-preserving cast. Final tests extend that control to present, changing, null-first, and null-second getters. + ## Issue #28: apply the selected matching policy before exact priority (2026-10-09) - Context: MatchSource selected a sole exact candidate even under IgnoreCase. Constructor matching already limits exact priority to Exact and ExactThenIgnoreCase. Structural helper planning also discarded an ambiguous match without reporting its specific member diagnostic. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 46817df..b83eca5 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #29: captured patch-mode nested updater input (2026-10-09) + +- Scope: pass the existing per-member patch capture to direct nested updater calls so the guard and invocation consume the same value exactly once. Preserve configured paths, Throw captures, null skipping, updater selection, and per-member update order. +- Test-first: cover declared and handwritten void/returning updaters, stable/changing/null getters, configured paths, writable/get-only targets, warning-free C# 9 output, and earlier assignments retained after later failure. Add a normally compiled/package-backed consumer example. +- Implementation: use the already allocated direct-member capture at the updater argument boundary; no new abstraction, public API, or specification semantics. +- Validation: focused/full generator, runtime/integration, source samples, available packaging/consumer/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI. +- Boundary: issue #29 only; stop at draft PR/human review. No merge, release, tag, package publication, or workflow dispatch. AgentStack CLI is absent; authorized connector fallback adds/readbacks readiness labels without issue comments or claim writes. + ## Issue #28: IgnoreCase member ambiguity (2026-10-09) - Scope: apply unique ordinal-ignore-case matching without exact-match priority to automatic source members. Preserve Exact and ExactThenIgnoreCase, constructor matching, hidden-member selection, and explicit configuration precedence. diff --git a/STATUS.md b/STATUS.md index 6fe1c0f..f18860e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,15 @@ # Mammoth.LiteMapper Status +## GitHub issue #29 patch nested updater capture checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop af3fd9ed403da20043705ce8e387578fbe7ad78c after PR #42 merged. Issue #29 was the oldest remaining P2, with no blocked-by dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. +- Red-first: 34 initial generator cases yielded 17 failures and 17 controls on unchanged production, zero skips. Present direct getters ran twice; changing getters mapped the wrong child or threw NullReferenceException on a second-read null. The normally compiled Basic example independently threw NullReferenceException. A nullable-struct overload control passed unchanged production, then failed the naive capture candidate (99 instead of 7); retaining the original nullable value type preserves the selected overload. +- Implementation: direct nested updater arguments reuse their existing patch capture, falling back to the existing Throw capture/direct expression. Direct nullable value captures are cast to their original source type for overload binding. Configured paths, per-member emission order, null skipping, updater eligibility, and public API are unchanged. Usage, consumer guidance, and changelog are synchronized. +- Verified core evidence: Release solution build has zero warnings/errors. All 837 generator tests pass, including 38 new cases, plus five runtime and one integration test, zero failures/skips. The combined new/nested/update/path subset passes 77/77. Fixtures compile under C# 9 without warnings and cover void/returning declared and handwritten updaters, get-only/writable children, changing/null getters, instance/nullable-input methods, Throw behavior, deterministic output, and retained earlier assignments/exception identity on later failure. Independent read-only review and consumer-skill/CRLF-aware diff checks pass. +- Verified package evidence: 224 passes, two infrastructure failures, zero skips in the available packaging suite. All 12 framework/language combinations, clean-package/source samples, usage, package/API checks, and benchmark smoke pass. Basic, matrix, and trimmed/AOT consumers include the changing-getter reproduction and null-preservation control. Actual netstandard2.0/C# 9 generated source was inspected: guard and invocation consume the same capture. +- Local limits: trimming and Native AOT fail at ILLink ComputeManagedAssemblies task-host creation with MSB4216 / Unix-domain SocketException 13, before publish behavior is established. PowerShell and clang are absent; 49 PowerShell-dependent cases were not run locally. No warning, assertion, dependency, prerequisite, credential, or security setting was weakened. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR. +- Boundary: issue #29 only, then human review. The previously documented nullable-intermediate enum defect remains outside scope. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch. + ## GitHub issue #28 IgnoreCase ambiguity checkpoint (2026-10-09) - Base: isolated cloud worktree from develop 14eaa974769554884d7f8ca71ff8821a6e19470f after PR #41 merged. Issue #28 was the oldest remaining P2, with no blocking dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. diff --git a/docs/USAGE.md b/docs/USAGE.md index ab29ab2..0fe07ca 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -653,6 +653,8 @@ For a writable non-null child, select the updater explicitly with `[MapProperty( Omitting `Source` in `[MapProperty(Target = nameof(Target.Child), Use = nameof(ApplyChild))]` passes the root source to `void ApplyChild(Source source, ChildTarget target)`. No source member named `Child` is required: the updater can copy root `Value == 3` into `target.Child.Value`. +The patch guard and nested updater share one captured source value: a nullable getter is evaluated once, even if another read would return a different object or null. The [Basic sample](../samples/Mammoth.LiteMapper.Samples.Basic/Program.cs) demonstrates this with `PatchUpdaterExample`. + Patch mode also applies to nested updater calls. If `source.Child` is null and `IgnoreNullSourceMembers` is enabled, the updater is skipped and the existing target child remains unchanged. With the default mismatch policy, passing a nullable child to a non-null updater parameter reports `LITEMAPPER2001`; `Throw` raises `InvalidOperationException` whose message includes the `Child` path. A destination-returning updater can create a nullable writable child. For example, `ChildTarget ApplyChild(ChildSource source, ChildTarget? target)` may return a new child; the parent mapping assigns that return value. A nullable get-only child cannot store such a replacement and reports `LITEMAPPER5005`. diff --git a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs index de758b2..974fbbe 100644 --- a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs @@ -9,6 +9,7 @@ internal static class Program private static void Main() { EnumPathExample.Run(); + PatchUpdaterExample.Run(); var staticTarget = StaticMapper.Map(new StaticSource { Name = "Ada", @@ -233,3 +234,41 @@ public sealed class EnumPathSource { public EnumPathChild? Child { get; set; } } public sealed class EnumPathChild { public InputState State { get; set; } } public sealed class EnumPathTarget { public OutputState State { get; set; } } } + +namespace Mammoth.LiteMapper.Samples.Basic +{ + public static class PatchUpdaterExample + { + public static void Run() + { + var source = new PatchUpdaterSource(new PatchUpdaterChild { Value = 7 }); + var target = new PatchUpdaterTarget(); + var original = target.Child; + PatchUpdaterMapper.Apply(source, target); + if (source.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child)) + throw new System.InvalidOperationException("Patch updater must use its captured child once."); + var missing = new PatchUpdaterSource(null); + PatchUpdaterMapper.Apply(missing, target); + if (missing.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child)) + throw new System.InvalidOperationException("A null patch child must preserve the existing destination."); + } + } + + [LiteMapper(IgnoreNullSourceMembers = true)] + public static partial class PatchUpdaterMapper + { + public static partial void Apply(PatchUpdaterSource source, PatchUpdaterTarget target); + public static partial void ApplyChild(PatchUpdaterChild source, PatchUpdaterChildTarget target); + } + + public sealed class PatchUpdaterSource + { + private readonly PatchUpdaterChild? first; + public PatchUpdaterSource(PatchUpdaterChild? first) { this.first = first; } + public int Reads; + public PatchUpdaterChild? Child { get { Reads++; return Reads == 1 ? first : null; } } + } + public sealed class PatchUpdaterChild { public int Value { get; set; } } + public sealed class PatchUpdaterChildTarget { public int Value { get; set; } } + public sealed class PatchUpdaterTarget { public PatchUpdaterChildTarget Child { get; } = new PatchUpdaterChildTarget(); } +} diff --git a/skills/mammoth-litemapper/references/mapping-rules.md b/skills/mammoth-litemapper/references/mapping-rules.md index bfb5f9f..13616c5 100644 --- a/skills/mammoth-litemapper/references/mapping-rules.md +++ b/skills/mammoth-litemapper/references/mapping-rules.md @@ -156,4 +156,4 @@ For a writable non-null child, select the updater explicitly with `[MapProperty( Omitting `Source` in `[MapProperty(Target = nameof(Target.Child), Use = nameof(ApplyChild))]` passes the root source to `void ApplyChild(Source source, ChildTarget target)`. No source member named `Child` is required: the updater can copy root `Value == 3` into `target.Child.Value`. -Nested updater calls obey patch and nullability rules. With `IgnoreNullSourceMembers`, null `source.Child` skips the updater and preserves the destination. Under the default mismatch policy, nullable child to a non-null updater parameter reports `LITEMAPPER2001`; `Throw` identifies the member path. A returning updater may create and return a nullable writable child, and the parent assigns it. A nullable get-only child cannot store a returned replacement and reports `LITEMAPPER5005`. +Patch guards and nested updater calls share the same captured source value, so direct nullable getters are read once and null skips the updater. Direct nullable value captures preserve the selected updater overload. Nested updater calls obey patch and nullability rules. With `IgnoreNullSourceMembers`, null `source.Child` skips the updater and preserves the destination. Under the default mismatch policy, nullable child to a non-null updater parameter reports `LITEMAPPER2001`; `Throw` identifies the member path. A returning updater may create and return a nullable writable child, and the parent assigns it. A nullable get-only child cannot store a returned replacement and reports `LITEMAPPER5005`. diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 1ec1b20..ae5f057 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -766,7 +766,12 @@ private static MappingModel CreateUpdateMappingModel(IMethodSymbol method, Compi ? escapedSourceName : selected != null ? sourcePathCaptureName ?? throwCaptureName ?? BuildNullSafeSourcePathExpression(source.Name, selected) - : throwCaptureName ?? escapedSourceName + "." + EscapeIdentifier(match.Member.Name); + : sourcePathCaptureName ?? throwCaptureName ?? escapedSourceName + "." + EscapeIdentifier(match.Member.Name); + if (selected == null && sourcePathCaptureName != null && nestedSourceType.IsValueType) + { + // A pattern capture unwraps Nullable; retain the selected input type for overload binding. + argument = "(" + DisplayType(nestedSourceType) + ")" + argument; + } string? updaterGuard = null; PreconditionModel? updaterPrecondition = null; if (sourceMayBeNull) diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/PatchNestedUpdaterCaptureTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/PatchNestedUpdaterCaptureTests.cs new file mode 100644 index 0000000..dae433c --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/PatchNestedUpdaterCaptureTests.cs @@ -0,0 +1,230 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + [TestClass] + public sealed class PatchNestedUpdaterCaptureTests + { + [TestMethod] + [DataRow("declared-void", "present")] + [DataRow("declared-void", "null")] + [DataRow("declared-void", "changing")] + [DataRow("declared-void", "null-second")] + [DataRow("declared-return", "present")] + [DataRow("declared-return", "null")] + [DataRow("declared-return", "changing")] + [DataRow("declared-return", "null-second")] + [DataRow("default-void", "present")] + [DataRow("default-void", "null")] + [DataRow("default-void", "changing")] + [DataRow("default-void", "null-second")] + [DataRow("default-return", "present")] + [DataRow("default-return", "null")] + [DataRow("default-return", "changing")] + [DataRow("default-return", "null-second")] + [DataRow("explicit-void", "present")] + [DataRow("explicit-void", "null")] + [DataRow("explicit-void", "changing")] + [DataRow("explicit-void", "null-second")] + [DataRow("explicit-return", "present")] + [DataRow("explicit-return", "null")] + [DataRow("explicit-return", "changing")] + [DataRow("explicit-return", "null-second")] + public void PatchGuardAndNestedUpdaterConsumeTheSameValue(string updaterKind, string getter) + { + var result = Run(Fixture(updaterKind)); + CollectionAssert.AreEqual(new[] { 1, getter == "null" ? 0 : 1, getter == "null" ? 42 : 7, 1 }, + Execute(result, getter), + "A nested updater must consume the guarded first value once, or leave the existing child untouched when it is null."); + } + + [TestMethod] + [DataRow("present")] + [DataRow("null")] + [DataRow("changing")] + [DataRow("null-second")] + public void ConfiguredNestedPathRetainsSingleEvaluation(string getter) + { + var source = Fixture("explicit-return") + .Replace("Source = nameof(Source.Child)", "Source = \"Container.Child\"") + .Replace("public sealed class Source\n{", "public sealed class Source\n{\n private int containerReads;\n public Source Container { get { if (++containerReads > 1) throw new Exception(\"Container read twice.\"); return this; } }"); + CollectionAssert.AreEqual(new[] { 1, getter == "null" ? 0 : 1, getter == "null" ? 42 : 7, 1 }, + Execute(Run(source), getter), + "Configured-path segments and the leaf must continue to share their patch capture."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void InstanceUpdaterUsesTheDirectCapture(bool nullableParameter) + { + var source = Fixture("default-void") + .Replace("public static partial class Mapper", "public partial class Mapper") + .Replace("public static partial Target Apply", "public partial Target Apply") + .Replace("private static void ApplyChild", "private void ApplyChild") + .Replace("target.Value = source.Value;", "target.Value = " + (nullableParameter ? "source?.Value ?? -1" : "source.Value") + ";") + .Replace("var returned = Mapper.Apply(source, target);", "var returned = new Mapper().Apply(source, target);"); + if (nullableParameter) + source = source.Replace("ApplyChild(ChildSource source", "ApplyChild(ChildSource? source"); + CollectionAssert.AreEqual(new[] { 1, 1, 7, 1 }, Execute(Run(source), "changing"), + "Instance and nullable-parameter updaters must receive the guarded source instance."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void LaterFailureRetainsEarlierAssignmentsAndDoesNotReadLaterMembers(bool updaterThrows) + { + var source = Fixture("default-void") + .Replace("target.Value = source.Value;", "target.Value = source.Value;" + (updaterThrows ? " throw Probe.Failure;" : "")) + .Replace("public ChildSource? Child", "public int Alpha => 3;\n public int Later => throw Probe.Failure;\n public int Zulu => throw new Exception(\"Later member must not run.\");\n public ChildSource? Child") + .Replace("public sealed class Target\n{", "public sealed class Target\n{\n public int Alpha { get; set; }\n public int Later { get; set; } = 51;\n public int Zulu { get; set; } = 61;") + .Replace("public static class Probe\n{", "public static class Probe\n{\n public static readonly Exception Failure = new Exception(\"Expected failure.\");") + .Replace("var returned = Mapper.Apply(source, target);", @" + try { Mapper.Apply(source, target); throw new Exception(""Expected failure was lost.""); } + catch (Exception error) when (object.ReferenceEquals(error, Failure)) { } + var returned = target; + if (target.Alpha != 3 || target.Later != 51 || target.Zulu != 61) + throw new Exception(""Member updates lost their sequential failure behavior."");"); + CollectionAssert.AreEqual(new[] { 1, 1, 7, 1 }, Execute(Run(source), "changing"), + "The direct capture stays inside its member update; a later failure preserves earlier mutations and exception identity."); + } + + [TestMethod] + public void ThrowPolicyStillConsumesTheGuardedDirectValue() + { + var source = Fixture("declared-return") + .Replace("IgnoreNullSourceMembers = true", "NullableMismatch = NullableMismatchPolicy.Throw"); + CollectionAssert.AreEqual(new[] { 1, 1, 7, 1 }, Execute(Run(source), "null-second"), + "Adding patch capture preference must preserve the existing Throw-policy capture."); + } + + [TestMethod] + public void PatchCaptureRemainsDeterministicWithUnusualParameterNames() + { + var source = Fixture("declared-void") + .Replace("Source source, Target target", "Source __value_Child, Target __source_Child"); + var first = Run(source); + var second = Run(source); + CollectionAssert.AreEqual(new[] { 1, 1, 7, 1 }, Execute(first, "changing")); + Assert.AreEqual(first.Source, second.Source, + "Equivalent compilation inputs must retain identical generated capture names and output."); + } + + [TestMethod] + [DataRow("present")] + [DataRow("null")] + [DataRow("changing")] + [DataRow("null-second")] + public void NullableValueCapturePreservesSelectedUpdaterOverload(string getter) + { + var source = Fixture("default-void") + .Replace("public sealed class ChildSource", "public struct ChildSource") + .Replace("ApplyChild(ChildSource source", "ApplyChild(ChildSource? source") + .Replace("target.Value = source.Value;", "target.Value = source?.Value ?? -1;") + .Replace("[DefaultMapping]", "private static void ApplyChild(ChildSource source, ChildTarget target) { target.Value = 99; } [DefaultMapping]"); + var actual = Execute(Run(source), getter); + Assert.AreEqual(getter == "null" ? 42 : 7, actual[2], "Unwrapping a capture must not make an unmarked overload override the selected nullable updater."); + Assert.AreEqual(1, actual[0], "Nullable value getters must be captured exactly once too."); + } + + private static string Fixture(string updaterKind) + { + var returns = updaterKind.EndsWith("return", StringComparison.Ordinal); + var declared = updaterKind.StartsWith("declared", StringComparison.Ordinal); + var explicitlySelected = updaterKind.StartsWith("explicit", StringComparison.Ordinal); + var updater = declared + ? "public static partial " + (returns ? "ChildTarget" : "void") + " ApplyChild(ChildSource source, ChildTarget target);" + : (explicitlySelected ? "" : "[DefaultMapping] ") + "private static " + (returns ? "ChildTarget" : "void") + + " ApplyChild(ChildSource source, ChildTarget target) { target.Value = source.Value;" + (returns ? " return target;" : "") + " }"; + return (@" +#nullable enable +using System; +using Mammoth.LiteMapper; +[LiteMapper(IgnoreNullSourceMembers = true)] +public static partial class Mapper +{ + " + (explicitlySelected ? "[MapProperty(Source = nameof(Source.Child), Target = nameof(Target.Child), Use = nameof(ApplyChild))]" : "") + @" + public static partial Target Apply(Source source, Target target); + " + updater + @" +} +public sealed class Source +{ + private readonly string behavior; + private int reads; + private readonly ChildSource first = new ChildSource { Value = 7 }; + public Source(string behavior) { this.behavior = behavior; } + public int ReadCount() => reads; + public ChildSource? Child + { + get + { + reads++; + if (behavior == ""null"") return null; + if (reads == 1 || behavior == ""present"") return first; + return behavior == ""null-second"" ? null : new ChildSource { Value = 99 }; + } + } +} +public sealed class ChildSource { public int Value { get; set; } } +public sealed class ChildTarget +{ + private int value = 42; + private int writes; + public int Value { get => value; set { writes++; this.value = value; } } + public int WriteCount() => writes; +} +public sealed class Target +{ + public ChildTarget Child { get; " + (explicitlySelected ? "set;" : "") + @" } = new ChildTarget(); +} +public static class Probe +{ + public static int[] Run(string behavior) + { + var source = new Source(behavior); + var target = new Target(); + var child = target.Child; + var returned = Mapper.Apply(source, target); + return new[] { source.ReadCount(), child.WriteCount(), target.Child.Value, + object.ReferenceEquals(target, returned) && object.ReferenceEquals(child, target.Child) ? 1 : 0 }; + } +} +").Replace("\r\n", "\n"); + } + + private static (Compilation Compilation, string Source) Run(string source) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + var references = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!.Split(Path.PathSeparator) + .Select(path => MetadataReference.CreateFromFile(path)) + .Concat(new[] { MetadataReference.CreateFromFile(typeof(LiteMapperAttribute).Assembly.Location) }); + var compilation = CSharpCompilation.Create("PatchUpdater_" + Guid.NewGuid().ToString("N"), + new[] { CSharpSyntaxTree.ParseText(source, parseOptions) }, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var inputErrors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && d.Id != "CS8795").ToArray(); + Assert.AreEqual(0, inputErrors.Length, string.Join(Environment.NewLine, inputErrors.Select(d => d.ToString()))); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new LiteMapperGenerator().AsSourceGenerator() }, parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out _); + Assert.AreEqual(0, driver.GetRunResult().Diagnostics.Length, + string.Join(Environment.NewLine, driver.GetRunResult().Diagnostics)); + var problems = updatedCompilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error || d.Severity == DiagnosticSeverity.Warning).ToArray(); + Assert.AreEqual(0, problems.Length, string.Join(Environment.NewLine, problems.Select(d => d.ToString()))); + return (updatedCompilation, string.Join(Environment.NewLine, driver.GetRunResult().GeneratedTrees.Select(t => t.ToString()))); + } + + private static T Execute((Compilation Compilation, string Source) result, params object[] arguments) + { + using var stream = new MemoryStream(); + var emit = result.Compilation.Emit(stream); + Assert.IsTrue(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + return (T)Assembly.Load(stream.ToArray()).GetType("Probe")!.GetMethod("Run")!.Invoke(null, arguments)!; + } + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index 5d428a1..5e667ea 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs @@ -54,7 +54,7 @@ public void PackageMappingsCompileAndExecuteAcrossFrameworksAndLanguages(string Milestone14PackagingAndAotTests.RunDotnet("restore --no-cache", consumer); Milestone14PackagingAndAotTests.RunDotnet("build -c Release --no-restore", consumer); var generated = Directory.GetFiles(Path.Combine(consumer, "obj", "generated"), "*.g.cs", SearchOption.AllDirectories); - Assert.AreEqual(7, generated.Length, "All seven mapper containers must generate in " + framework + "/" + language + "."); + Assert.AreEqual(8, generated.Length, "All eight mapper containers must generate in " + framework + "/" + language + "."); var executable = consumer; if (standard) @@ -178,6 +178,7 @@ public static class MatrixFixture public static void Run() { EnumPathExample.Run(); + PatchUpdaterExample.Run(); if (Environment.Version.Major != EXPECTED_RUNTIME_MAJOR) throw new InvalidOperationException(""Wrong runtime was used.""); var nullableChildren = NullableElementMapper.Map(new ChildSource?[] { null, new ChildSource { Value = 7 }, null }); if (nullableChildren.Count != 3 || nullableChildren[0] != null || nullableChildren[1]?.Value != 7 || nullableChildren[2] != null) @@ -340,6 +341,42 @@ public sealed class EnumPathSource { public EnumPathChild? Child { get; set; } } public sealed class EnumPathChild { public InputState State { get; set; } } public sealed class EnumPathTarget { public OutputState State { get; set; } } +public static class PatchUpdaterExample +{ + public static void Run() + { + var source = new PatchUpdaterSource(new PatchUpdaterChild { Value = 7 }); + var target = new PatchUpdaterTarget(); + var original = target.Child; + PatchUpdaterMapper.Apply(source, target); + if (source.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child)) + throw new System.InvalidOperationException(""Patch updater must use its captured child once.""); + var missing = new PatchUpdaterSource(null); + PatchUpdaterMapper.Apply(missing, target); + if (missing.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child)) + throw new System.InvalidOperationException(""A null patch child must preserve the existing destination.""); + } +} + +[LiteMapper(IgnoreNullSourceMembers = true)] +public static partial class PatchUpdaterMapper +{ + public static partial void Apply(PatchUpdaterSource source, PatchUpdaterTarget target); + public static partial void ApplyChild(PatchUpdaterChild source, PatchUpdaterChildTarget target); +} + +public sealed class PatchUpdaterSource +{ + private readonly PatchUpdaterChild? first; + public PatchUpdaterSource(PatchUpdaterChild? first) { this.first = first; } + public int Reads; + public PatchUpdaterChild? Child { get { Reads++; return Reads == 1 ? first : null; } } +} +public sealed class PatchUpdaterChild { public int Value { get; set; } } +public sealed class PatchUpdaterChildTarget { public int Value { get; set; } } +public sealed class PatchUpdaterTarget { public PatchUpdaterChildTarget Child { get; } = new PatchUpdaterChildTarget(); } + + "; } } diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs index 1952b81..baa9ca7 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -224,6 +224,7 @@ private static string CreateConsumer(string feed, string targetFramework) using Mammoth.LiteMapper; EnumPathExample.Run(); + PatchUpdaterExample.Run(); var nullableChildren = NullableElementMapper.Map(new ChildSource?[] { null, new ChildSource { Value = 7 }, null }); if (nullableChildren.Count != 3 || nullableChildren[0] != null || nullableChildren[1]?.Value != 7 || nullableChildren[2] != null) @@ -438,6 +439,42 @@ public sealed class EnumPathSource { public EnumPathChild? Child { get; set; } } public sealed class EnumPathChild { public InputState State { get; set; } } public sealed class EnumPathTarget { public OutputState State { get; set; } } +public static class PatchUpdaterExample +{ + public static void Run() + { + var source = new PatchUpdaterSource(new PatchUpdaterChild { Value = 7 }); + var target = new PatchUpdaterTarget(); + var original = target.Child; + PatchUpdaterMapper.Apply(source, target); + if (source.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child)) + throw new System.InvalidOperationException(""Patch updater must use its captured child once.""); + var missing = new PatchUpdaterSource(null); + PatchUpdaterMapper.Apply(missing, target); + if (missing.Reads != 1 || target.Child.Value != 7 || !object.ReferenceEquals(original, target.Child)) + throw new System.InvalidOperationException(""A null patch child must preserve the existing destination.""); + } +} + +[LiteMapper(IgnoreNullSourceMembers = true)] +public static partial class PatchUpdaterMapper +{ + public static partial void Apply(PatchUpdaterSource source, PatchUpdaterTarget target); + public static partial void ApplyChild(PatchUpdaterChild source, PatchUpdaterChildTarget target); +} + +public sealed class PatchUpdaterSource +{ + private readonly PatchUpdaterChild? first; + public PatchUpdaterSource(PatchUpdaterChild? first) { this.first = first; } + public int Reads; + public PatchUpdaterChild? Child { get { Reads++; return Reads == 1 ? first : null; } } +} +public sealed class PatchUpdaterChild { public int Value { get; set; } } +public sealed class PatchUpdaterChildTarget { public int Value { get; set; } } +public sealed class PatchUpdaterTarget { public PatchUpdaterChildTarget Child { get; } = new PatchUpdaterChildTarget(); } + + "); return directory; }