From b509219f9a96f78417f9a6b59e8227c603556dec Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Fri, 9 Oct 2026 12:09:22 +0200 Subject: [PATCH] fix: preserve nullable structural collection elements Guard nullable structural elements before object helpers, retaining nullable targets and nested collection input annotations without changing converter precedence. Fixes #26. Co-authored-by: Codex --- CHANGELOG.md | 1 + DECISIONS.md | 7 + IMPLEMENTATION_PLAN.md | 8 + STATUS.md | 11 + docs/USAGE.md | 8 + .../Program.cs | 16 + .../LiteMapperGenerator.cs | 17 +- .../NullableStructuralElementTests.cs | 346 ++++++++++++++++++ .../ConsumerMatrixTests.cs | 14 +- .../Milestone14PackagingAndAotTests.cs | 12 + 10 files changed, 438 insertions(+), 2 deletions(-) create mode 100644 tests/Mammoth.LiteMapper.Generator.Tests/NullableStructuralElementTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 768249c..263d6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- Issue #26: structural collection mapping preserves nullable elements and applies Throw guards before object helpers; nullable nested collection helper signatures retain their annotations. [#26](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/26) - Issue #25: cycle detection now forwards one context through acyclic object and collection bridges, retaining complete member paths without tracking the bridge objects. [#25](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/25) - Issue #24: partial mappers with framework attributes or `UseMapper` on another part now generate once; genuine duplicate `LiteMapper` configuration still reports one fatal diagnostic. [#24](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/24) - Issue #23: explicit omitted-`Source` converters receive the root object before automatic source-member matching in new-object, constructor-bound, and update mappings. Unrelated source names and patch getters no longer override that selection. [#23](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/23) diff --git a/DECISIONS.md b/DECISIONS.md index bcdeec0..18b1286 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Decision Log +## Issue #26: enforce nullability at the structural element boundary (2026-10-09) + +- Context: language conversion creates a nullable Throw guard before probing conversion, but discards it when structural mapping is required. The element fallback then passed null directly into a deliberately non-null object helper. Nullable target elements also had no preserving branch. +- Decision: after higher-precedence conversion and collection stages, preserve nullable structural targets with a single-evaluation pattern capture and guard explicitly non-null targets under Throw. Error retains its existing LITEMAPPER2003 rejection. Structural helpers remain normalized to non-null inputs; collection helper signatures retain the nullable types stored by their planner. +- Contract: sections 12.4, 14.3, 15.5, 15.8, 15.10, and 22.11-22.12 already require these semantics. The fix changes no public API, converter precedence, collection null policy, helper identity, or cycle/instance context. +- Evidence: 31 initial cases on unchanged production yield 23 failures and eight Error/converter controls, zero skips. Cases assert runtime counts/order, single enumeration and member reads, exception type/path, C# 9 compilation without warnings, nullable containers, patch members, instance converters, and recursive/shared references. The Collections sample independently throws NullReferenceException before the fix. + ## Issue #25: distinguish context carriers from tracked vertices (2026-10-09) - Context: recursive-helper membership was also used to decide which helpers receive tracker arguments. An acyclic helper calling a recursive helper therefore referenced undeclared context locals. Broadening that same set would incorrectly track acyclic bridge objects. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1ef380a..698c01a 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #26: nullable structural collection elements (2026-10-09) + +- Scope: preserve null elements when structurally mapping distinct source/destination classes; apply the element Error/Throw policy before a non-null structural helper is invoked. Cover arrays, lists, enumerable/interface targets, dictionary values, nested collections, and existing member/update paths. +- Test-first: add compiling contract fixtures, prove runtime and generated-warning failures on unchanged develop, and retain Error-policy controls before editing production. +- Implementation: apply the null boundary only after the structural fallback has been selected. Reuse deterministic capture naming, existing helper signatures, and cycle/instance plumbing; preserve higher-precedence converter behavior and single evaluation. +- Validation: focused/full generator checks, runtime/integration, source and clean-package samples, consumer framework/language matrix, available packaging checks, independent review, and ordinary exact-head PR CI. Disclose unavailable local stages separately. +- Boundary: issue #26 only. No public API or specification semantics change, other issue, merge, release, tag push, package publication, or workflow dispatch. Stop at draft PR/human review. + ## Issue #25: cycle context through acyclic bridges (2026-10-09) - Scope: separate cycle-context forwarding from active-path vertex tracking. Acyclic object, struct, and collection bridges carry the initiating entry's tracker, member path, and method name to recursive helpers without entering the tracker themselves. diff --git a/STATUS.md b/STATUS.md index 46d3cb7..c595e63 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,16 @@ # Mammoth.LiteMapper Status +## GitHub issue #26 nullable structural element checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop 38e313ef836f9400dd0eeaea56d48ba35dca733d after PR #39 merged. Issue #26 was the oldest remaining P2, with no blocking dependencies, sub-issues, or competing open PRs. User-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: 31 new generator cases on unchanged production produced 23 failures and eight passing Error/converter controls, zero skips. Runtime cases exposed NullReferenceException, and generated compilation exposed CS8604. The Collections sample independently threw NullReferenceException. Test inputs were checked for unrelated compiler errors before generation. +- Implementation: guard the selected structural element fallback, using a pattern capture for nullable targets and a coalescing InvalidOperationException guard under Throw. Higher-precedence converters and the existing Error diagnostic remain unchanged. Preserve collection helper source annotations; structural helper input/output types are already normalized to non-null. +- Verified core evidence: all 31 focused cases and all 727 generator tests pass, plus five runtime and one integration test, zero failures/skips. Final Release solution build has zero warnings/errors; all three source samples pass. Generated netstandard2.0 package source was inspected and captures each element before its object helper call. Independent review found no scoped blocker; CRLF-aware diff validation passes. +- Package coverage: the Collections sample, clean-package consumers, all 12 framework/language fixtures, and trimming/Native AOT fixture include nullable structural elements. Final available packaging run: 220 passed, two infrastructure failures, zero skips. All 12 framework/language combinations, clean-package samples, package/API checks, usage validation, and benchmark smoke pass. Trimming and Native AOT fail before publish validation at ILLink ComputeManagedAssemblies task-host creation (MSB4216 / Unix-domain SocketException 13). No local trimming/AOT success is claimed. +- Validation recovery: an initial sample placed nullable and non-null collection mappings in one mapper, invoking unrelated declared-mapping selection and producing CS8619; the example now has its own mapper. The consumer fixture count was updated from five to six for the added mapper. That initial packaging run was stopped; a corrected attempt was interrupted by automatic approval cancellation and produced no final TRX. The completed final retry supplies the available-package evidence above. +- Environment: existing SDK 10.0.401 and runtimes 8.0.31/9.0.20/10.0.12, workspace-local caches, one-processor builds. PowerShell and clang are absent; the final available-package command excludes 49 PowerShell-dependent cases. No warning, assertion, dependency, prerequisite, credential, or security setting was weakened. +- Boundary: issue #26 only, then draft PR/human review. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch. Ordinary exact-head CI evidence belongs in the draft PR. + ## GitHub issue #25 cycle bridge checkpoint (2026-10-09) - Base: isolated cloud worktree from develop a68bb3e97d6697da9ef06b7ac65e07ea7c6c42ed after PR #38 merged. Issue #25 had no blocked-by dependencies, sub-issues, or competing PRs. User-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 5df54c4..8fe18d9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -681,6 +681,12 @@ public static partial class CollectionMapper { public static partial OrderTarget Map(OrderSource source); } + +[LiteMapper] +public static partial class NullableLineMapper +{ + public static partial List MapNullableLines(LineSource?[] source); +} ``` Supported collection mapping includes: @@ -692,6 +698,8 @@ Supported collection mapping includes: - nested collections and collections of nested objects; - dictionary key and value conversion. +Nullable structural elements preserve null without calling an object-mapping helper. In the Collections sample, `MapNullableLines` maps a three-element source containing null, a line with quantity 7, and null to three elements with the same null positions and mapped quantity. This also applies to dictionary values and nested collections. Mapping a nullable element to a non-null target reports `LITEMAPPER2003` under `NullableMismatchPolicy.Error`; `Throw` checks the element before mapping and raises `InvalidOperationException` with its source element path. `NullCollectionStrategy.Empty` applies to the collection itself and does not replace or remove null object elements. + LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocated only when cheap count or length information is available. Comparers are preserved for compatible set and dictionary shapes. Unsupported shapes include custom collections, immutable collections, queues, stacks, and rectangular multidimensional arrays. For array results from sources without a cheap, reliable count, mapping uses an O(n) temporary growing buffer and then creates the final array. It does not count by enumerating first, and each element is converted once. Counted sources allocate their final array directly. The same rule applies to interfaces whose concrete result is an array. diff --git a/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs b/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs index a871e90..60f9d14 100644 --- a/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs @@ -8,6 +8,16 @@ internal static class Program { private static void Main() { + var nullableLines = NullableLineMapper.MapNullableLines(new LineSource?[] + { + null, new LineSource { Sku = "A", Quantity = 7 }, null, + }); + if (nullableLines.Count != 3 || nullableLines[0] != null || + nullableLines[1]?.Quantity != 7 || nullableLines[2] != null) + { + throw new InvalidOperationException("Nullable collection elements were not preserved."); + } + var target = CollectionMapper.Map(new OrderSource { Id = 100, @@ -41,6 +51,12 @@ public static partial class CollectionMapper public static partial OrderTarget Map(OrderSource source); } + [LiteMapper] + public static partial class NullableLineMapper + { + public static partial List MapNullableLines(LineSource?[] source); + } + public sealed class OrderSource { public int Id { get; set; } diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 96c45dc..07b5c82 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -2177,6 +2177,21 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, helpers.Add(helper); } + if (IsMaybeNull(sourceType)) + { + if (IsMaybeNull(targetType)) + { + var captureName = CreateExpressionCaptureName(method, "item", expression); + return expression + " is { } " + captureName + " ? " + helperName + "(" + captureName + ") : null"; + } + + if (options.NullableMismatch == NullableMismatchPolicyThrow && + (targetType.IsValueType || targetType.NullableAnnotation == NullableAnnotation.NotAnnotated)) + { + expression = "(" + expression + " ?? throw new global::System.InvalidOperationException(\"Source member '" + expression + "' was null.\"))"; + } + } + return helperName + "(" + expression + ")"; } @@ -4092,7 +4107,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H builder.Append(' '); builder.Append(EscapeIdentifier(mapping.HelperName ?? method.Name)); builder.Append('('); - builder.Append(mapping.HelperSourceType == null ? DisplayType(parameter.Type) : DisplayType(mapping.HelperSourceType).TrimEnd('?')); + builder.Append(DisplayType(mapping.HelperSourceType ?? parameter.Type)); builder.Append(' '); builder.Append(EscapeIdentifier(parameter.Name)); if (mapping.IsUpdate && destination != null) diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/NullableStructuralElementTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/NullableStructuralElementTests.cs new file mode 100644 index 0000000..0645678 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/NullableStructuralElementTests.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + [TestClass] + public sealed class NullableStructuralElementTests + { + [TestMethod] + [DataRow("array", "Error")] + [DataRow("list", "Error")] + [DataRow("enumerable", "Error")] + [DataRow("readonly", "Error")] + [DataRow("dictionary", "Error")] + [DataRow("jagged", "Error")] + [DataRow("nested_list", "Error")] + [DataRow("array", "Throw")] + public void NullableTargetsPreserveEveryElementAndItsOrder(string shape, string policy) + { + var result = Generate(Fixture(shape, policy, nullableTarget: true)); + AssertRuns(result); + } + + [TestMethod] + [DataRow("array")] + [DataRow("list")] + [DataRow("enumerable")] + [DataRow("readonly")] + [DataRow("dictionary")] + [DataRow("jagged")] + [DataRow("nested_list")] + public void ThrowRejectsNullBeforeCallingTheStructuralHelper(string shape) + { + var result = Generate(Fixture(shape, "Throw", nullableTarget: false)); + AssertRuns(result); + } + + [TestMethod] + [DataRow("array")] + [DataRow("list")] + [DataRow("enumerable")] + [DataRow("readonly")] + [DataRow("dictionary")] + [DataRow("jagged")] + [DataRow("nested_list")] + public void ErrorRejectsNullableElementsAndOmitsTheirImplementation(string shape) + { + var result = Generate(Fixture(shape, "Error", nullableTarget: false)); + Assert.IsTrue(result.RunResult.Diagnostics.Any(diagnostic => diagnostic.Id == "LITEMAPPER2003"), + string.Join(Environment.NewLine, result.RunResult.Diagnostics)); + Assert.IsFalse(result.RunResult.Diagnostics.Any(diagnostic => diagnostic.Id == "LITEMAPPER9001")); + Assert.AreEqual(0, result.RunResult.GeneratedTrees.Length, + "An invalid element contract must not emit a partial implementation or silently filter nulls."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void MemberAndPatchCollectionMappingStillPreserveNullElements(bool update) + { + var source = @" +using System; +using Mammoth.LiteMapper; +[LiteMapper(OPTIONS)] +public static partial class Mapper { public static partial METHOD; } +public sealed class Root { public Source?[]? Items { get; set; } } +public sealed class RootDto { public Target?[]? Items { get; set; } } +" + ElementTypes + @" +public static class Probe +{ + public static bool Run() + { + var root = new Root { Items = new Source?[] { null, new Source(7), null, new Source(9) } }; + var destination = new RootDto(); + var result = CALL; + if (result.Items == null || result.Items.Length != 4 || result.Items[0] != null || + result.Items[1]?.Value != 7 || result.Items[2] != null || result.Items[3]?.Value != 9) return false; + var previous = result.Items; + root.Items = null; + var nullResult = CALL; + return EXPECTED; + } +} +"; + source = source.Replace("OPTIONS", update ? "IgnoreNullSourceMembers = true" : "") + .Replace("METHOD", update ? "RootDto Map(Root source, RootDto destination)" : "RootDto Map(Root source)") + .Replace("CALL", update ? "Mapper.Map(root, destination)" : "Mapper.Map(root)") + .Replace("EXPECTED", update ? "ReferenceEquals(previous, nullResult.Items)" : "nullResult.Items == null"); + AssertRuns(Generate(source)); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void NullableNestedCollectionsPreserveBothNullContainersAndNullObjects(bool nullLeaf) + { + var result = Generate(@" +using Mammoth.LiteMapper; +[LiteMapper] +public static partial class Mapper { public static partial Target?[]?[] Map(Source?[]?[] source); } +" + ElementTypes + @" +public static class Probe +{ + public static bool Run() + { + var source = new Source?[]?[] { null, new Source?[] { LEAF, new Source(7) }, new Source?[0] }; + var mapped = Mapper.Map(source); + return mapped.Length == 3 && mapped[0] == null && mapped[1]?.Length == 2 && + mapped[1]?[0]?.Value == EXPECTED && mapped[1]?[1]?.Value == 7 && mapped[2]?.Length == 0; + } +} +".Replace("LEAF", nullLeaf ? "null" : "new Source(3)").Replace("EXPECTED", nullLeaf ? "null" : "3")); + AssertRuns(result); + } + + [TestMethod] + public void EmptyCollectionStrategyDoesNotReplaceOrDropNullStructuralElements() + { + var source = Fixture("array", "Error", nullableTarget: true) + .Replace("NullableMismatch = NullableMismatchPolicy.Error", "NullCollections = NullCollectionStrategy.Empty"); + AssertRuns(Generate(source)); + } + + [TestMethod] + public void NullableElementsForwardCycleContextAndSkipNullBeforeTracking() + { + var result = Generate(@" +using System; +using Mammoth.LiteMapper; +[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] +public static partial class Mapper { public static partial Target?[] Map(Source?[] source); } +public sealed class Source { public Source? Next { get; set; } public int Value { get; set; } } +public sealed class Target { public Target? Next { get; set; } public int Value { get; set; } } +public static class Probe +{ + public static bool Run() + { + var node = new Source { Value = 7 }; + var source = new Source?[] { null, node, node }; + var mapped = Mapper.Map(source); + if (mapped.Length != 3 || mapped[0] != null || mapped[1]?.Value != 7 || + mapped[2]?.Value != 7 || ReferenceEquals(mapped[1], mapped[2])) return false; + node.Next = node; + try { Mapper.Map(source); return false; } + catch (LiteMapperCycleException error) + { + if (error.MappingMethod != ""Map"" || error.MemberPath != ""Next"" || + error.SourceType != typeof(Source) || error.DestinationType != typeof(Target)) return false; + } + node.Next = null; + return Mapper.Map(source)[1]?.Value == 7; + } +} +"); + AssertRuns(result); + var generated = string.Join(Environment.NewLine, result.RunResult.GeneratedTrees); + Assert.AreEqual(1, CSharpSyntaxTree.ParseText(generated).GetRoot().DescendantNodes() + .OfType().Count(node => node.Type.ToString() == "__LiteMapperCycleTracker")); + } + + [TestMethod] + public void HigherPrecedenceNullableConverterReceivesNullWithoutStructuralInterception() + { + AssertRuns(Generate(@" +using Mammoth.LiteMapper; +[LiteMapper] +public static partial class Mapper +{ + public static int Calls; + public static partial Target[] Map(Source?[] source); + [MappingConverter] private static Target Convert(Source? source) + { + Calls++; + return new Target { Value = source == null ? -1 : source.Value + 10 }; + } +} +" + ElementTypes + @" +public static class Probe +{ + public static bool Run() + { + var mapped = Mapper.Map(new Source?[] { null, new Source(7) }); + return mapped.Length == 2 && mapped[0].Value == -1 && mapped[1].Value == 17 && Mapper.Calls == 2; + } +} +")); + } + + [TestMethod] + public void InstanceConvertersStillRunOnlyForPresentStructuralElements() + { + AssertRuns(Generate(@" +using Mammoth.LiteMapper; +[LiteMapper] +public sealed partial class Mapper +{ + private readonly int offset; + public int Calls; + public Mapper(int offset) { this.offset = offset; } + public partial Target?[] Map(Source?[] source); + [MappingConverter] private int Convert(int source) { Calls++; return source + offset; } +} +" + ElementTypes + @" +public static class Probe +{ + public static bool Run() + { + var mapper = new Mapper(10); + var other = new Mapper(20); + var source = new Source?[] { null, new Source(7) }; + var first = mapper.Map(source); + var second = other.Map(source); + return first[0] == null && first[1]?.Value == 17 && second[0] == null && second[1]?.Value == 27 && + mapper.Calls == 1 && other.Calls == 1; + } +} +")); + } + + [TestMethod] + public void GeneratedNullBoundariesAreWarningFreeAndDeterministic() + { + var source = Fixture("dictionary", "Error", nullableTarget: true); + var first = Generate(source); + var second = Generate(source); + AssertClean(first); + Assert.AreEqual(string.Join(Environment.NewLine, first.RunResult.GeneratedTrees), + string.Join(Environment.NewLine, second.RunResult.GeneratedTrees)); + Assert.IsFalse(string.Join(Environment.NewLine, first.RunResult.GeneratedTrees).Contains("default!", StringComparison.Ordinal)); + } + + private static string Fixture(string shape, string policy, bool nullableTarget) + { + var (sourceType, targetType, initialize, values) = shape switch + { + "array" => ("Source?[]", "Target?[]", "elements", "mapped"), + "list" => ("List", "List", "new List(elements)", "mapped"), + "enumerable" => ("IEnumerable", "Target?[]", "Enumerate(elements)", "mapped"), + "readonly" => ("IReadOnlyList", "IReadOnlyList", "elements", "mapped"), + "dictionary" => ("Dictionary", "Dictionary", "new Dictionary { [\"first\"] = elements[0], [\"second\"] = elements[1], [\"third\"] = elements[2], [\"fourth\"] = elements[3] }", "new[] { mapped[\"first\"], mapped[\"second\"], mapped[\"third\"], mapped[\"fourth\"] }"), + "jagged" => ("Source?[][]", "Target?[][]", "new[] { elements, new Source?[0] }", "mapped[0]"), + "nested_list" => ("List>", "List>", "new List> { new List(elements), new List() }", "mapped[0]"), + _ => throw new ArgumentOutOfRangeException(nameof(shape)), + }; + if (!nullableTarget) targetType = targetType.Replace("Target?", "Target"); + var source = @" +using System; +using System.Collections.Generic; +using System.Linq; +using Mammoth.LiteMapper; +[LiteMapper(NullableMismatch = NullableMismatchPolicy.POLICY)] +public static partial class Mapper { public static partial TARGET Map(SOURCE source); } +ELEMENT_TYPES +public static class Probe +{ + private static int enumerations; + private static int yields; + private static IEnumerable Enumerate(Source?[] elements) + { + if (++enumerations != 1) throw new InvalidOperationException(""Repeated enumeration.""); + foreach (var element in elements) { yields++; yield return element; } + } + public static bool Run() + { + var elements = new Source?[] { null, new Source(7), null, new Source(9) }; + var source = INITIAL; + BODY + } +} +".Replace("POLICY", policy).Replace("TARGET", targetType).Replace("SOURCE", sourceType) + .Replace("ELEMENT_TYPES", ElementTypes).Replace("INITIAL", initialize); + var preserve = @" + var mapped = Mapper.Map(source); + var values = VALUES; + if (values.Count() != 4 || values.ElementAt(0) != null || values.ElementAt(1)?.Value != 7 || + values.ElementAt(2) != null || values.ElementAt(3)?.Value != 9 || Source.Reads != 2 || Target.Created != 2) return false; + EXTRA + return true;".Replace("VALUES", values).Replace("EXTRA", shape == "enumerable" ? "if (enumerations != 1 || yields != 4) return false;" : + shape == "jagged" ? "if (mapped.Length != 2 || mapped[1].Length != 0) return false;" : + shape == "nested_list" ? "if (mapped.Count != 2 || mapped[1].Count != 0) return false;" : ""); + var reject = @" + try { Mapper.Map(source); return false; } + catch (InvalidOperationException error) + { + return error.GetType() == typeof(InvalidOperationException) && error.Message.Contains(""PATH"") && + Source.Reads == 0 && Target.Created == 0; + }".Replace("PATH", shape == "dictionary" ? "item.Value" : "item"); + return source.Replace("BODY", nullableTarget ? preserve : reject); + } + + private const string ElementTypes = @" +public sealed class Source +{ + private readonly int value; + public static int Reads; + public Source(int value) { this.value = value; } + public int Value { get { Reads++; return value; } } +} +public sealed class Target +{ + public static int Created; + public Target() { Created++; } + public int Value { get; set; } +} +"; + + private static void AssertRuns((GeneratorDriverRunResult RunResult, Compilation Compilation) result) + { + Assert.IsFalse(result.RunResult.Diagnostics.Any(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning), + string.Join(Environment.NewLine, result.RunResult.Diagnostics)); + using var stream = new MemoryStream(); + var emitted = result.Compilation.Emit(stream); + Assert.IsTrue(emitted.Success, string.Join(Environment.NewLine, emitted.Diagnostics)); + Assert.AreEqual(true, Assembly.Load(stream.ToArray()).GetType("Probe")!.GetMethod("Run")!.Invoke(null, null)); + AssertClean(result); + } + + private static void AssertClean((GeneratorDriverRunResult RunResult, Compilation Compilation) result) + { + var problems = result.Compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning).ToArray(); + Assert.AreEqual(0, problems.Length, string.Join(Environment.NewLine, problems.Select(diagnostic => diagnostic.ToString()))); + } + + private static (GeneratorDriverRunResult RunResult, Compilation Compilation) Generate(string source) + { + 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 parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + var compilation = CSharpCompilation.Create("NullableStructuralElementTests", new[] { CSharpSyntaxTree.ParseText(source, parseOptions) }, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var inputErrors = compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error && diagnostic.Id != "CS8795").ToArray(); + Assert.AreEqual(0, inputErrors.Length, "Invalid fixture: " + string.Join(Environment.NewLine, inputErrors.Select(diagnostic => diagnostic.ToString()))); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new LiteMapperGenerator().AsSourceGenerator() }, parseOptions: parseOptions); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var updated, out _); + return (driver.GetRunResult(), updated); + } + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index 1fb3c12..abdabfb 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(5, generated.Length, "All five mapper containers must generate in " + framework + "/" + language + "."); + Assert.AreEqual(6, generated.Length, "All six mapper containers must generate in " + framework + "/" + language + "."); var executable = consumer; if (standard) @@ -140,6 +140,12 @@ public static class MatrixFixture public static void 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) + { + throw new System.InvalidOperationException(""Nullable structural elements were not preserved.""); + } + var source = new Source { Name = ""Ada"", Scores = new[] { 1, 2 }, Children = new[] { new ChildSource { Value = 7 } }, State = SourceState.Ready }; var target = StaticMapper.Map(source); if (target.Name != ""Ada"" || target.Children.Count != 1 || target.Children[0].Value != 7 || target.State != TargetState.Ready) @@ -216,6 +222,12 @@ public sealed class Target public List Children { get; set; } = new List(); public TargetState State { get; set; } } +[LiteMapper] +public static partial class NullableElementMapper +{ + public static partial List Map(ChildSource?[] source); +} + public sealed class ChildSource { public int Value { get; set; } } public sealed class ChildTarget { public int Value { get; set; } } [LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs index 0011b45..15778f4 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -223,6 +223,12 @@ private static string CreateConsumer(string feed, string targetFramework) using System.Collections.Generic; using Mammoth.LiteMapper; +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) +{ + throw new System.InvalidOperationException(""Nullable structural elements were not preserved.""); +} + var target = StaticMapper.ToTarget(new Source { Name = ""Ada"", @@ -317,6 +323,12 @@ public sealed class Target public List Children { get; set; } = new List(); } +[LiteMapper] +public static partial class NullableElementMapper +{ + public static partial List Map(ChildSource?[] source); +} + public sealed class ChildSource { public int Value { get; set; }