From 807644f0fec8ba651456cf147ce943e96f24994f Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Fri, 9 Oct 2026 11:36:35 +0200 Subject: [PATCH] fix: forward cycle context through acyclic helpers Separate recursive source tracking from helper context forwarding, preserving complete paths and one tracker across object and collection bridges. Fixes #25. Co-authored-by: Codex --- CHANGELOG.md | 1 + DECISIONS.md | 8 + IMPLEMENTATION_PLAN.md | 8 + STATUS.md | 10 + docs/USAGE.md | 2 + .../Program.cs | 45 ++ .../LiteMapperGenerator.cs | 73 ++-- .../CycleBridgeContextTests.cs | 393 ++++++++++++++++++ .../ConsumerMatrixTests.cs | 47 ++- .../Milestone14PackagingAndAotTests.cs | 46 ++ 10 files changed, 603 insertions(+), 30 deletions(-) create mode 100644 tests/Mammoth.LiteMapper.Generator.Tests/CycleBridgeContextTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index f9207de..768249c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- 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) - Issue #22: generated nested and collection helpers preserve the calling mapper instance for stateful converters and mappings, while instance-free helpers remain static. Static entry methods no longer select instance-only mappings. [#22](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/22) diff --git a/DECISIONS.md b/DECISIONS.md index fb932f3..bcdeec0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Decision Log +## 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. +- Decision: maintain separate recursive-vertex and context-forwarding sets. Identify mixed declared/helper components from declared-call seeds independently of pure helper cycles; take the reverse helper-call closure only for context forwarding. Recursive public roots use the same Enter/finally/Exit contract as recursive helpers, while acyclic roots only create the single context. +- Paths: public collection bodies forward an empty initial path. Member append handles an empty incoming path, avoiding a leading dot while retaining the public method name and existing member-based collection paths. +- Contract: sections 14.1, 15, 17.2-17.5, 22.14, and 28.6-28.7 already require these semantics. No public context API, reflection, generated mutable mapper state, or normative change is introduced. +- Evidence: failing-before-fix compile/runtime/source-structure cases cover object and collection bridges, shared siblings, direct/indirect cycles, mixed declared components, constructors/updates, stateful instance converters, value-type bridges, concurrent calls, and None/acyclic controls. Basic and package consumer fixtures exercise the same wrapped collection cycle. + ## Issue #24: discover the marker-owning partial declaration once (2026-10-09) - Context: broad attributed-type discovery sees the merged LiteMapper marker on every attributed partial part. Repeated planning yields duplicate diagnostics and source hint collisions that discard unrelated generated output. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 349b9ad..1ef380a 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## 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. +- Test-first: 19 new cases on unchanged develop yielded 17 failures and two controls; an additional mixed declared/helper component test failed the first candidate's over-tracking assertion before its repair. The Basic sample independently reproduced CS0103 for all three missing context locals. +- Implementation: preserve structural recursive helper membership and separately seed mixed declared/helper components, then compute the callerward context closure. Only recursive reference vertices Enter/Exit, including a recursive public root; root collections start with an empty path and helper members append without a leading dot. +- Validation: 20 new generator cases, complete generator/runtime/integration suites, source samples, clean-package samples, all 12 framework/language consumers, trimming/AOT consumer coverage, available packaging checks, independent review, and ordinary exact-head PR CI. Record unavailable stages separately. +- Boundary: issue #25 only; no public API or specification semantics change. Stop at draft PR/human review; no merge, release, tag, package publication, or workflow dispatch. + ## Issue #24: one discovery per partial mapper (2026-10-09) - Scope: a single marker-owning declaration per mapper symbol, retaining the established incremental candidate identity. Other partial declarations may carry framework attributes or UseMapper registrations without duplicating planning, diagnostics, or generated source. Genuine duplicate LiteMapper markers remain invalid. diff --git a/STATUS.md b/STATUS.md index 2d07a1e..46d3cb7 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,15 @@ # Mammoth.LiteMapper Status +## 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. +- Red-first: the completed initial 19-case matrix on unchanged production produced 17 failures and two passing None/acyclic controls, zero skips. The twentieth mixed declared/helper regression then failed the first candidate's over-tracking assertion before correction. A separate Basic sample build failed with missing tracker/path/method-name CS0103 errors. A direct-root cleanup assertion also failed before finally handling was unified. +- Implementation: distinct recursive vertices and context carriers; backward helper context propagation; preserved mixed declared/helper components; one tracker at the public recursive entry; no Enter/Exit for acyclic bridge objects; complete collection-root and nested paths. No public API or specification change. +- Verified local generator/runtime evidence: Release solution build has zero warnings/errors. All 696 generator tests pass, including 20 new cases; runtime five and integration one pass, zero failures/skips. All three source sample smoke runs pass. Generated bridge paths, active-path boundaries, and tracker allocation counts are asserted and executed. Available packaging suite: 220 passed, two infrastructure failures, zero skips, including all 12 framework/language combinations, clean-package samples, package/API checks, usage validation, and benchmark smoke. Actual netstandard2.0 package-generated source was inspected and confirms the forwarding/tracking boundary and complete path. Independent review found no code or coverage blocker; CRLF-aware diff validation passes. Ordinary exact-head CI belongs in the draft PR. +- Package scope: normal clean-package samples, the 12 framework/language consumers, and trimmed/Native AOT package consumers now execute the wrapped object/array/list cycle and shared-sibling example. +- Environment: existing workspace SDK 10.0.401 and runtimes 8.0.31/9.0.20/10.0.12, workspace NuGet caches, and one-processor builds. PowerShell and clang are absent; 49 PowerShell-dependent cases are not run locally. Trimming and Native AOT fail at ILLink ComputeManagedAssemblies task-host creation (MSB4216 / Unix-domain SocketException 13), before publish behavior is established. No local trimming/AOT success is claimed. No dependency, warning, assertion, credential, or security setting was weakened. +- Boundary: issue #25 only, then draft PR/human review. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch. + ## GitHub issue #24 partial-discovery checkpoint (2026-10-09) - Base: isolated cloud worktree from develop 1583ddbcff4e14e677d92acba86d222567196824 after PR #37 merged. Issue #24 is open with no dependencies/sub-issues or competing PR. The user authorized one next issue; exec:agent and ready:agent were added and read back. AgentStack CLI is absent; authorized connector fallback is used without issue comments or claim writes. diff --git a/docs/USAGE.md b/docs/USAGE.md index 4603d09..5df54c4 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -867,6 +867,8 @@ public static partial class CycleMapper `ReferenceHandling.ThrowOnCycle` enables generated cycle tracking for recursive type graphs. A detected cycle throws `LiteMapperCycleException`. Non-recursive mappings do not allocate cycle-tracker state. +Acyclic object and collection wrappers can lead into a recursive graph. The same tracker is forwarded through those wrappers, while only recursive reference objects participate in cycle detection. The Basic sample maps `WrappedSource.Envelope.Nodes`; a self-cycle in a node reports `Envelope.Nodes.Next`, and a shared node in separate collection positions maps independently. + `LiteMapperCycleException` exposes `SourceType`, `DestinationType`, `MappingMethod`, and `MemberPath` for the detected active-path cycle. It does not expose `SourcePath` or `DestinationPath` properties. Recursive components may pass through value types and declared mapping methods. For example, an `A -> B -> A` graph mapped by declared `MapA` and `MapB` methods uses one tracker; a cycle reports `MapA` and member path `B.A`. A class-to-struct-to-class path forwards the tracker through the struct without tracking or boxing the struct itself. diff --git a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs index 5b4e569..dd4a52c 100644 --- a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs @@ -37,6 +37,25 @@ private static void Main() throw new InvalidOperationException("Instance mapping failed."); } + var sharedNode = new NodeSource(); + var wrapped = new WrappedSource { Envelope = new EnvelopeSource { Nodes = new[] { sharedNode, sharedNode } } }; + var wrappedTarget = WrappedCycleMapper.Map(wrapped); + if (wrappedTarget.Envelope.Nodes.Count != 2 || + ReferenceEquals(wrappedTarget.Envelope.Nodes[0], wrappedTarget.Envelope.Nodes[1])) + { + throw new InvalidOperationException("Wrapped mapping failed."); + } + sharedNode.Next = sharedNode; + try + { + WrappedCycleMapper.Map(wrapped); + throw new InvalidOperationException("Wrapped cycle detection failed."); + } + catch (LiteMapperCycleException error) when ( + error.MappingMethod == "Map" && error.MemberPath == "Envelope.Nodes.Next") + { + } + var node = new NodeSource(); node.Next = node; try @@ -132,6 +151,32 @@ public sealed class FormattedChildTarget public string Value { get; set; } = string.Empty; } + [LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] + public static partial class WrappedCycleMapper + { + public static partial WrappedTarget Map(WrappedSource source); + } + + public sealed class WrappedSource + { + public EnvelopeSource Envelope { get; set; } = new EnvelopeSource(); + } + + public sealed class WrappedTarget + { + public EnvelopeTarget Envelope { get; set; } = new EnvelopeTarget(); + } + + public sealed class EnvelopeSource + { + public NodeSource[] Nodes { get; set; } = new NodeSource[0]; + } + + public sealed class EnvelopeTarget + { + public List Nodes { get; set; } = new List(); + } + public sealed class NodeSource { public NodeSource? Next { get; set; } diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 7bb541e..96c45dc 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -3891,9 +3891,14 @@ private static string RenderDeclaration(INamedTypeSymbol symbol, ImmutableArray< var declaredRecursive = mapping.ReferenceHandling == ReferenceHandlingThrowOnCycle && recursiveDeclaredMappings.Contains(mapping.Method); if (declaredRecursive) { - IncludeTrackerDependentHelpers(mapping, recursiveHelperNames, recursiveDeclaredMappings); - } - needsCycleTracker = needsCycleTracker || recursiveHelperNames.Count != 0 || declaredRecursive; + var declaredComponentHelpers = new HashSet(StringComparer.Ordinal); + IncludeTrackerDependentHelpers(mapping, declaredComponentHelpers, recursiveDeclaredMappings, includeDeclaredMappings: true); + recursiveHelperNames.UnionWith(declaredComponentHelpers); + } + // Acyclic callers forward context without becoming active-path vertices. + var trackerHelperNames = new HashSet(recursiveHelperNames, StringComparer.Ordinal); + IncludeTrackerDependentHelpers(mapping, trackerHelperNames, recursiveDeclaredMappings, includeDeclaredMappings: false); + needsCycleTracker = needsCycleTracker || trackerHelperNames.Count != 0 || declaredRecursive; if (declaredRecursive) { AppendDeclaredTrackingWrapper(builder, mapping); @@ -3902,17 +3907,17 @@ private static string RenderDeclaration(INamedTypeSymbol symbol, ImmutableArray< mapping.Assignments, mapping.Helpers, mapping.Method.Name, mapping.Method.Parameters[0].Type, mapping.IsUpdate && mapping.DestinationParameter != null ? mapping.DestinationParameter.Type : mapping.Method.ReturnType, mapping.CustomBody, mapping.IsUpdate, mapping.DestinationParameter, mapping.DestinationNullable, isDeclaredCore: true); - AppendMapping(builder, core, recursiveHelperNames, recursiveDeclaredMappings, instanceHelperNames); + AppendMapping(builder, core, recursiveHelperNames, trackerHelperNames, recursiveDeclaredMappings, instanceHelperNames); } else { - AppendMapping(builder, mapping, recursiveHelperNames, recursiveDeclaredMappings, instanceHelperNames); + AppendMapping(builder, mapping, recursiveHelperNames, trackerHelperNames, recursiveDeclaredMappings, instanceHelperNames); } foreach (var helper in FlattenHelpers(mapping).OrderBy(static h => h.HelperName, StringComparer.Ordinal)) { if (emittedHelpers.Add(helper.HelperName!)) { - AppendMapping(builder, helper, recursiveHelperNames, recursiveDeclaredMappings, instanceHelperNames); + AppendMapping(builder, helper, recursiveHelperNames, trackerHelperNames, recursiveDeclaredMappings, instanceHelperNames); } } } @@ -4022,7 +4027,7 @@ private static IEnumerable CalledMappings(MappingModel mapping) return called; } - private static void IncludeTrackerDependentHelpers(MappingModel mapping, HashSet trackedNames, HashSet recursiveDeclaredMappings) + private static void IncludeTrackerDependentHelpers(MappingModel mapping, HashSet trackedNames, HashSet recursiveDeclaredMappings, bool includeDeclaredMappings) { var helpers = FlattenHelpers(mapping).Where(static helper => helper.HelperName != null).ToArray(); var allNames = helpers.Select(static helper => helper.HelperName!).Concat(trackedNames).Distinct(StringComparer.Ordinal).ToArray(); @@ -4034,7 +4039,7 @@ private static void IncludeTrackerDependentHelpers(MappingModel mapping, HashSet { if (!trackedNames.Contains(helper.HelperName!) && (CalledHelpers(helper, allNames).Overlaps(trackedNames) || - helper.DeclaredCalls.Any(recursiveDeclaredMappings.Contains))) + includeDeclaredMappings && helper.DeclaredCalls.Any(recursiveDeclaredMappings.Contains))) { trackedNames.Add(helper.HelperName!); changed = true; @@ -4043,7 +4048,7 @@ private static void IncludeTrackerDependentHelpers(MappingModel mapping, HashSet } } - private static void AppendMapping(StringBuilder builder, MappingModel mapping, HashSet recursiveHelperNames, HashSet recursiveDeclaredMappings, HashSet instanceHelperNames) + private static void AppendMapping(StringBuilder builder, MappingModel mapping, HashSet recursiveHelperNames, HashSet trackerHelperNames, HashSet recursiveDeclaredMappings, HashSet instanceHelperNames) { var method = mapping.Method; var parameter = method.Parameters[0]; @@ -4054,8 +4059,16 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H var mappingMethodExpression = mapping.HelperName == null ? "\"" + method.Name + "\"" : mappingMethodLocal; var destinationCreatedLocal = CreateUniqueIdentifier(method, "__destinationCreated", trackerLocal, memberPathLocal, mappingMethodLocal); var targetLocal = CreateUniqueIdentifier(method, "target", trackerLocal, memberPathLocal, mappingMethodLocal, destinationCreatedLocal); - var trackedHelper = mapping.HelperName != null && (mapping.IsDeclaredCore || recursiveHelperNames.Contains(mapping.HelperName)); - var trackedPublicEntry = mapping.HelperName == null && mapping.ReferenceHandling == ReferenceHandlingThrowOnCycle && recursiveHelperNames.Count != 0; + var trackedHelper = mapping.HelperName != null && (mapping.IsDeclaredCore || trackerHelperNames.Contains(mapping.HelperName)); + var trackedPublicEntry = mapping.HelperName == null && mapping.ReferenceHandling == ReferenceHandlingThrowOnCycle && trackerHelperNames.Count != 0; + var sourceType = mapping.HelperSourceType ?? parameter.Type; + var targetType = mapping.HelperTargetType ?? destination?.Type ?? method.ReturnType; + var trackedSource = !sourceType.IsValueType && + (trackedHelper && (mapping.IsDeclaredCore || recursiveHelperNames.Contains(mapping.HelperName!)) || + trackedPublicEntry && FlattenHelpers(mapping).Any(helper => + recursiveHelperNames.Contains(helper.HelperName!) && + SymbolEqualityComparer.Default.Equals(helper.HelperSourceType, sourceType) && + SymbolEqualityComparer.Default.Equals(helper.HelperTargetType, targetType))); var trackedMapping = trackedHelper || trackedPublicEntry; if (mapping.EmitAggressiveInlining) { @@ -4148,24 +4161,20 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H if (trackedPublicEntry) { builder.AppendLine(" var " + trackerLocal + " = new __LiteMapperCycleTracker();"); - if (!parameter.Type.IsValueType) - { - builder.AppendLine(" " + trackerLocal + ".Enter(" + EscapeIdentifier(parameter.Name) + "!, typeof(" + DisplayType(parameter.Type).TrimEnd('?') + "), typeof(" + DisplayType(method.ReturnType).TrimEnd('?') + "), " + mappingMethodExpression + ", string.Empty);"); - } } - if (trackedHelper && mapping.HelperSourceType != null && !mapping.HelperSourceType.IsValueType) + if (trackedSource) { - builder.AppendLine(" " + trackerLocal + ".Enter(" + EscapeIdentifier(parameter.Name) + "!, typeof(" + DisplayType(mapping.HelperSourceType).TrimEnd('?') + "), typeof(" + DisplayType(mapping.HelperTargetType!).TrimEnd('?') + "), " + mappingMethodExpression + ", " + memberPathLocal + ");"); + builder.AppendLine(" " + trackerLocal + ".Enter(" + EscapeIdentifier(parameter.Name) + "!, typeof(" + DisplayType(sourceType).TrimEnd('?') + "), typeof(" + DisplayType(targetType).TrimEnd('?') + "), " + mappingMethodExpression + ", " + (trackedPublicEntry ? "string.Empty" : memberPathLocal) + ");"); builder.AppendLine(" try"); builder.AppendLine(" {"); } if (mapping.CustomBody != null) { - builder.Append(RenderTrackedCalls(mapping.CustomBody, recursiveHelperNames, recursiveDeclaredMappings, trackedMapping, - mapping.DeclaredCalls, memberPathLocal, trackerLocal, mappingMethodExpression)); - if (trackedHelper && mapping.HelperSourceType != null && !mapping.HelperSourceType.IsValueType) + builder.Append(RenderTrackedCalls(mapping.CustomBody, trackerHelperNames, recursiveDeclaredMappings, trackedMapping, + mapping.DeclaredCalls, mapping.HelperName == null ? "string.Empty" : memberPathLocal, trackerLocal, mappingMethodExpression)); + if (trackedSource) { builder.AppendLine(" }"); builder.AppendLine(" finally"); @@ -4202,13 +4211,13 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H builder.Append('('); if (mapping.Construction != null) { - builder.Append(string.Join(", ", mapping.Construction.Arguments.Select(argument => EscapeIdentifier(argument.ParameterName) + ": " + RenderTrackedCalls(argument.Expression, recursiveHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(argument.DeclaredMapping), PathExpression(mapping, argument.ParameterName, memberPathLocal), trackerLocal, mappingMethodExpression)))); + builder.Append(string.Join(", ", mapping.Construction.Arguments.Select(argument => EscapeIdentifier(argument.ParameterName) + ": " + RenderTrackedCalls(argument.Expression, trackerHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(argument.DeclaredMapping), PathExpression(mapping, argument.ParameterName, memberPathLocal), trackerLocal, mappingMethodExpression)))); } builder.Append(')'); if (creationAssignments.Length > 0) { builder.Append(" { "); - builder.Append(string.Join(", ", creationAssignments.Select(assignment => EscapeIdentifier(assignment.TargetName) + " = " + RenderTrackedCalls(assignment.InitializationExpression, recursiveHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(assignment.DeclaredMapping), PathExpression(mapping, assignment.TargetName, memberPathLocal), trackerLocal, mappingMethodExpression)))); + builder.Append(string.Join(", ", creationAssignments.Select(assignment => EscapeIdentifier(assignment.TargetName) + " = " + RenderTrackedCalls(assignment.InitializationExpression, trackerHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(assignment.DeclaredMapping), PathExpression(mapping, assignment.TargetName, memberPathLocal), trackerLocal, mappingMethodExpression)))); builder.Append(" }"); } builder.AppendLine(";"); @@ -4262,7 +4271,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H builder.Append(EscapeIdentifier(assignment.TargetName)); builder.Append(" = "); } - builder.Append(RenderTrackedCalls(assignment.Expression, recursiveHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(assignment.DeclaredMapping), PathExpression(mapping, assignment.TargetName, memberPathLocal), trackerLocal, mappingMethodExpression)); + builder.Append(RenderTrackedCalls(assignment.Expression, trackerHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(assignment.DeclaredMapping), PathExpression(mapping, assignment.TargetName, memberPathLocal), trackerLocal, mappingMethodExpression)); builder.AppendLine(";"); if (guard != null) { @@ -4277,6 +4286,14 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H builder.AppendLine(";"); } + if (trackedSource) + { + builder.AppendLine(" }"); + builder.AppendLine(" finally"); + builder.AppendLine(" {"); + builder.AppendLine(" " + trackerLocal + ".Exit(" + EscapeIdentifier(parameter.Name) + "!);"); + builder.AppendLine(" }"); + } builder.AppendLine(" }"); return; } @@ -4297,7 +4314,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H first = false; builder.Append(EscapeIdentifier(argument.ParameterName)); builder.Append(": "); - builder.Append(RenderTrackedCalls(argument.Expression, recursiveHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(argument.DeclaredMapping), PathExpression(mapping, argument.ParameterName, memberPathLocal), trackerLocal, mappingMethodExpression)); + builder.Append(RenderTrackedCalls(argument.Expression, trackerHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(argument.DeclaredMapping), PathExpression(mapping, argument.ParameterName, memberPathLocal), trackerLocal, mappingMethodExpression)); } } @@ -4316,7 +4333,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H builder.Append(" "); builder.Append(EscapeIdentifier(assignment.TargetName)); builder.Append(" = "); - builder.Append(RenderTrackedCalls(assignment.Expression, recursiveHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(assignment.DeclaredMapping), PathExpression(mapping, assignment.TargetName, memberPathLocal), trackerLocal, mappingMethodExpression)); + builder.Append(RenderTrackedCalls(assignment.Expression, trackerHelperNames, recursiveDeclaredMappings, trackedMapping, DeclaredCallSequence(assignment.DeclaredMapping), PathExpression(mapping, assignment.TargetName, memberPathLocal), trackerLocal, mappingMethodExpression)); builder.AppendLine(i == mapping.Assignments.Length - 1 ? string.Empty : ","); } @@ -4324,7 +4341,7 @@ private static void AppendMapping(StringBuilder builder, MappingModel mapping, H } builder.AppendLine(" return " + targetLocal + ";"); - if (trackedHelper && mapping.HelperSourceType != null && !mapping.HelperSourceType.IsValueType) + if (trackedSource) { builder.AppendLine(" }"); builder.AppendLine(" finally"); @@ -4464,9 +4481,7 @@ private static string PathExpression(MappingModel mapping, string memberName, st { return "\"" + memberName + "\""; } - return mapping.IsDeclaredCore - ? "(" + memberPathName + ".Length == 0 ? \"" + memberName + "\" : " + memberPathName + " + \"." + memberName + "\")" - : memberPathName + " + \"." + memberName + "\""; + return "(" + memberPathName + ".Length == 0 ? \"" + memberName + "\" : " + memberPathName + " + \"." + memberName + "\")"; } private static IEnumerable DeclaredCallSequence(IMethodSymbol? declaredMapping) diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/CycleBridgeContextTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/CycleBridgeContextTests.cs new file mode 100644 index 0000000..e721824 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/CycleBridgeContextTests.cs @@ -0,0 +1,393 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Mammoth.LiteMapper; +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 CycleBridgeContextTests + { + [TestMethod] + [DataRow(0, "static")] + [DataRow(1, "static")] + [DataRow(3, "static")] + [DataRow(3, "instance")] + [DataRow(3, "update")] + [DataRow(3, "constructor")] + [DataRow(3, "struct")] + public void ObjectBridgesForwardContextWithoutTrackingAcyclicVertices(int depth, string mode) + { + var result = Generate(ObjectFixture(depth, mode)); + AssertRuns(result); + AssertTrackingBoundary(result.Source, "Node"); + } + + [TestMethod] + [DataRow("array", false)] + [DataRow("list", false)] + [DataRow("dictionary", false)] + [DataRow("array", true)] + [DataRow("list", true)] + [DataRow("dictionary", true)] + public void CollectionBridgesPreserveContextAtMemberAndPublicEntries(string shape, bool rootCollection) + { + var sourceType = shape == "array" ? "Bridge[]" : shape == "list" ? "List" : "Dictionary"; + var targetType = sourceType.Replace("Bridge", "BridgeDto"); + var initial = shape == "array" ? "new[] { bridge, bridge }" : shape == "list" ? "new List { bridge, bridge }" : "new Dictionary { [\"first\"] = bridge, [\"second\"] = bridge }"; + var first = shape == "dictionary" ? "[\"first\"]" : "[0]"; + var second = shape == "dictionary" ? "[\"second\"]" : "[1]"; + var source = @" +using System; +using System.Collections.Generic; +using Mammoth.LiteMapper; +[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] +public static partial class Mapper { public static partial TARGET Map(SOURCE source); } +public sealed class Root { public SOURCE_ITEMS Items { get; set; } = default!; } +public sealed class RootDto { public TARGET_ITEMS Items { get; set; } = default!; } +public sealed class Bridge { public Node Node { get; set; } = new Node(); } +public sealed class BridgeDto { public NodeDto Node { get; set; } = new NodeDto(); } +NODE_TYPES +public static class Probe +{ + public static bool Run() + { + var node = new Node { Value = 7, Next = new Node { Value = 9 } }; + var bridge = new Bridge { Node = node }; + var items = INITIAL; + var source = INPUT; + var mapped = Mapper.Map(source)ACCESS; + if (mappedFIRST.Node.Value != 7 || mappedSECOND.Node.Next?.Value != 9 || + ReferenceEquals(mappedFIRST, mappedSECOND) || ReferenceEquals(mappedFIRST.Node, mappedSECOND.Node)) return false; + node.Next = node; + try { Mapper.Map(source); return false; } + catch (LiteMapperCycleException error) + { + if (error.MemberPath != ""CYCLE_PATH"" || error.MappingMethod != ""Map"" || + error.SourceType != typeof(Node) || error.DestinationType != typeof(NodeDto)) return false; + } + node.Next = null; + return Mapper.Map(source)ACCESSFIRST.Node.Next == null; + } +} +".Replace("SOURCE_ITEMS", sourceType).Replace("TARGET_ITEMS", targetType) + .Replace("TARGET", rootCollection ? targetType : "RootDto").Replace("SOURCE", rootCollection ? sourceType : "Root") + .Replace("NODE_TYPES", NodeTypes).Replace("INITIAL", initial) + .Replace("INPUT", rootCollection ? "items" : "new Root { Items = items }") + .Replace("ACCESS", rootCollection ? "" : ".Items") + .Replace("FIRST", first).Replace("SECOND", second) + .Replace("CYCLE_PATH", rootCollection ? "Node.Next" : "Items.Node.Next"); + var result = Generate(source); + AssertRuns(result); + AssertTrackingBoundary(result.Source, "Node"); + } + + [TestMethod] + [DataRow("None", true)] + [DataRow("ThrowOnCycle", false)] + public void BridgesWithoutTrackedRecursionAllocateNoTracker(string policy, bool recursive) + { + var source = ObjectFixture(3, "static", policy, recursive); + var result = Generate(source); + AssertRuns(result); + Assert.IsFalse(result.Source.Contains("__LiteMapperCycleTracker", StringComparison.Ordinal), result.Source); + Assert.AreEqual(recursive ? 1 : 0, result.Diagnostics.Count(d => d.Id == "LITEMAPPER6001")); + } + + [TestMethod] + public void SeparateCallsAndSharedBranchesUseIndependentTrackingState() + { + var result = Generate(ObjectFixture(3, "static")); + var assembly = Compile(result); + var run = assembly.GetType("Probe")!.GetMethod("Run")!; + var outcomes = Enumerable.Range(0, 16).AsParallel().Select(_ => run.Invoke(null, null)).ToArray(); + Assert.IsTrue(outcomes.All(outcome => Equals(true, outcome))); + AssertTrackingBoundary(result.Source, "Node"); + } + + [TestMethod] + public void BridgeOutputIsDeterministic() + { + var source = ObjectFixture(3, "static"); + var first = Generate(source); + var second = Generate(source); + Assert.AreEqual(first.Source, second.Source); + AssertRuns(first); + } + + [TestMethod] + public void DirectRecursiveRootStillTracksItsOwnReferenceAndReleasesIt() + { + var result = Generate(@" +using Mammoth.LiteMapper; +[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] +public static partial class Mapper { public static partial NodeDto Map(Node source); } +" + NodeTypes + @" +public static class Probe +{ + public static bool Run() + { + var node = new Node { Value = 7 }; + node.Next = node; + try { Mapper.Map(node); return false; } + catch (LiteMapperCycleException error) + { + if (error.MemberPath != ""Next"" || error.MappingMethod != ""Map"" || + error.SourceType != typeof(Node) || error.DestinationType != typeof(NodeDto)) return false; + } + node.Next = null; + return Mapper.Map(node).Value == 7; + } +} +"); + AssertRuns(result); + AssertTrackingBoundary(result.Source, "Node"); + } + + [TestMethod] + public void BridgeIntoMutuallyRecursiveHelpersPreservesTheFullPath() + { + var result = Generate(@" +using Mammoth.LiteMapper; +[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] +public static partial class Mapper { public static partial RootDto Map(Root source); } +public sealed class Root { public Bridge Envelope { get; set; } = new Bridge(); } +public sealed class RootDto { public BridgeDto Envelope { get; set; } = new BridgeDto(); } +public sealed class Bridge { public A Node { get; set; } = new A(); } +public sealed class BridgeDto { public ADto Node { get; set; } = new ADto(); } +public sealed class A { public B? Next { get; set; } } +public sealed class B { public A? Back { get; set; } } +public sealed class ADto { public BDto? Next { get; set; } } +public sealed class BDto { public ADto? Back { get; set; } } +public static class Probe +{ + public static bool Run() + { + var node = new A { Next = new B() }; + var source = new Root { Envelope = new Bridge { Node = node } }; + if (Mapper.Map(source).Envelope.Node.Next?.Back != null) return false; + node.Next.Back = node; + try { Mapper.Map(source); return false; } + catch (LiteMapperCycleException error) + { + return error.MemberPath == ""Envelope.Node.Next.Back"" && error.MappingMethod == ""Map"" && + error.SourceType == typeof(A) && error.DestinationType == typeof(ADto); + } + } +} +"); + AssertRuns(result); + AssertTrackingBoundary(result.Source, "A", "B"); + } + + [TestMethod] + public void DeclaredRecursionKeepsMixedCycleVerticesSeparateFromAcyclicSideBridges() + { + var result = Generate(@" +using Mammoth.LiteMapper; +[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] +public static partial class Mapper +{ + public static partial ADto MapA(A source); + public static partial BDto MapB(B source); +} +public sealed class A { public Link Child { get; set; } = new Link(); public Side? Side { get; set; } } +public sealed class B { public A? Back { get; set; } } +public sealed class Link { public B? Node { get; set; } } +public sealed class Side { public Node Node { get; set; } = new Node(); } +public sealed class ADto { public LinkDto Child { get; set; } = new LinkDto(); public SideDto? Side { get; set; } } +public sealed class BDto { public ADto? Back { get; set; } } +public sealed class LinkDto { public BDto? Node { get; set; } } +public sealed class SideDto { public NodeDto Node { get; set; } = new NodeDto(); } +" + NodeTypes + @" +public static class Probe +{ + public static bool Run() + { + var source = new A(); + source.Child.Node = new B { Back = source }; + try { Mapper.MapA(source); return false; } + catch (LiteMapperCycleException error) + { + if (error.MappingMethod != ""MapA"" || error.MemberPath != ""Child.Node.Back"" || error.SourceType != typeof(A)) return false; + } + source.Child.Node = null; + source.Side = new Side(); + source.Side.Node.Next = source.Side.Node; + try { Mapper.MapA(source); return false; } + catch (LiteMapperCycleException error) + { + return error.MappingMethod == ""MapA"" && error.MemberPath == ""Side.Node.Next"" && error.SourceType == typeof(Node); + } + } +} +"); + AssertRuns(result); + var methods = CSharpSyntaxTree.ParseText(result.Source).GetRoot().DescendantNodes().OfType().ToArray(); + var sideHelpers = methods.Where(method => method.ParameterList.Parameters.FirstOrDefault()?.Type?.ToString() == "Side").ToArray(); + Assert.IsTrue(sideHelpers.Length > 0); + Assert.IsTrue(sideHelpers.All(method => !method.ToString().Contains(".Enter(", StringComparison.Ordinal)), + "A side bridge leading into an independent recursive component must only forward context."); + var mixedHelpers = methods.Where(method => method.ParameterList.Parameters.FirstOrDefault()?.Type?.ToString() == "Link").ToArray(); + Assert.IsTrue(mixedHelpers.Length > 0); + Assert.IsTrue(mixedHelpers.All(method => method.ToString().Contains(".Enter(", StringComparison.Ordinal)), + "The bridge inside a declared/helper recursive component must remain an active-path vertex."); + } + + private const string NodeTypes = @" +public sealed class Node +{ + public int Value { get; set; } + public Node? Next { get; set; } + public override bool Equals(object? other) => other is Node; + public override int GetHashCode() => 0; +} +public sealed class NodeDto { public int Value { get; set; } public NodeDto? Next { get; set; } } +"; + + private static string ObjectFixture(int depth, string mode, string policy = "ThrowOnCycle", bool recursive = true) + { + var types = NodeTypes; + var value = "node"; + var sourceType = "Node"; + var targetType = "NodeDto"; + var access = ""; + for (var index = 0; index < depth; index++) + { + var kind = mode == "struct" ? "struct" : "sealed class"; + var initializer = mode == "struct" ? "" : " = default!;"; + types += "public " + kind + " Bridge" + index + " { public " + sourceType + " Child { get; set; }" + initializer + " }\n"; + types += "public " + kind + " Bridge" + index + "Dto { public " + targetType + " Child { get; set; }" + initializer + " }\n"; + value = "new Bridge" + index + " { Child = " + value + " }"; + sourceType = "Bridge" + index; + targetType = sourceType + "Dto"; + access += ".Child"; + } + types += "public sealed class Root { public " + sourceType + " First { get; set; } = default!; public " + sourceType + " Second { get; set; } = default!; }\n"; + types += mode == "constructor" + ? "public sealed class RootDto { public " + targetType + " First { get; } public " + targetType + " Second { get; } public RootDto(" + targetType + " first, " + targetType + " second) { First = first; Second = second; } }\n" + : "public sealed class RootDto { public " + targetType + " First { get; set; } = default!; public " + targetType + " Second { get; set; } = default!; }\n"; + var method = mode == "update" ? "public static partial RootDto Map(Root source, RootDto destination);" + : "public " + (mode == "instance" ? "" : "static ") + "partial RootDto Map(Root source);"; + var call = mode == "instance" ? "new Mapper(10).Map(source)" : mode == "update" ? "Mapper.Map(source, destination)" : "Mapper.Map(source)"; + var cycle = policy == "None" || !recursive ? "" : @" + node.Next = node; + try { CALL; return false; } + catch (LiteMapperCycleException error) + { + if (error.MemberPath != ""PATH.Next"" || error.MappingMethod != ""Map"" || + error.SourceType != typeof(Node) || error.DestinationType != typeof(NodeDto)) return false; + } + var tail = new Node { Value = 11, Next = node }; + node.Next = tail; + try { CALL; return false; } + catch (LiteMapperCycleException error) { if (error.MemberPath != ""PATH.Next.Next"") return false; } + node.Next = null; + if (CALL.FirstACCESS.Next != null) return false; +"; + var fixture = @" +using System; +using Mammoth.LiteMapper; +[LiteMapper(ReferenceHandling = ReferenceHandling.POLICY)] +public MAPPER_KIND partial class Mapper { METHOD } +TYPES +public static class Probe +{ + public static bool Run() + { + var node = new Node { Value = 7, Next = new Node { Value = 9 } }; + var bridge = VALUE; + var source = new Root { First = bridge, Second = bridge }; + DESTINATION + var mapped = CALL; + if (mapped.FirstACCESS.Value != 7 || mapped.SecondACCESS.Next?.Value != 9 || + ReferenceEquals(mapped.FirstACCESS, mapped.SecondACCESS)) return false; + CYCLE + return true; + } +} +".Replace("POLICY", policy).Replace("MAPPER_KIND", mode == "instance" ? "sealed" : "static") + .Replace("METHOD", method + (mode == "instance" ? " private readonly int offset; public Mapper(int offset) { this.offset = offset; } [MappingConverter] private int Convert(int value) => offset + value;" : "")) + .Replace("TYPES", types).Replace("VALUE", value) + .Replace("DESTINATION", mode == "update" ? "var destination = new RootDto();" : "") + .Replace("CYCLE", cycle).Replace("CALL", call).Replace("ACCESS", access) + .Replace("PATH", (mode == "constructor" ? "first" : "First") + access); + if (mode == "instance") + { + fixture = fixture.Replace(".Value != 7", ".Value != 17").Replace(".Next?.Value != 9", ".Next?.Value != 19"); + } + return recursive ? fixture : fixture.Replace("public Node? Next { get; set; }", "public Leaf? Next { get; set; }") + .Replace("public NodeDto? Next { get; set; }", "public LeafDto? Next { get; set; }") + .Replace("Next = new Node { Value = 9 }", "Next = new Leaf { Value = 9 }") + + "public sealed class Leaf { public int Value { get; set; } } public sealed class LeafDto { public int Value { get; set; } }"; + } + + private static void AssertTrackingBoundary(string generated, params string[] recursiveSources) + { + var root = CSharpSyntaxTree.ParseText(generated).GetRoot(); + var trackerCreations = root.DescendantNodes().OfType() + .Count(expression => expression.Type.ToString() == "__LiteMapperCycleTracker"); + Assert.AreEqual(1, trackerCreations, "Exactly one tracker must be created by the public recursive call."); + foreach (var method in root.DescendantNodes().OfType().Where(method => method.Body != null)) + { + var enters = method.Body!.DescendantNodes().OfType() + .Where(call => call.Expression is MemberAccessExpressionSyntax member && member.Name.Identifier.ValueText == "Enter").ToArray(); + var sourceType = method.ParameterList.Parameters.FirstOrDefault()?.Type?.ToString(); + Assert.AreEqual(recursiveSources.Contains(sourceType) ? 1 : 0, enters.Length, + "Only recursive source vertices participate in active-path tracking: " + method.Identifier + "\n" + method); + if (enters.Length != 0) + { + Assert.IsTrue(method.Body.DescendantNodes().OfType().Any(), "Recursive helpers must release their source reference in finally."); + } + } + } + + private static void AssertRuns(GeneratorRun result) + { + var assembly = Compile(result); + Assert.AreEqual(true, assembly.GetType("Probe")!.GetMethod("Run")!.Invoke(null, null), result.Source); + } + + private static Assembly Compile(GeneratorRun result) + { + var errors = result.Compilation.GetDiagnostics().Where(d => d.Severity >= DiagnosticSeverity.Warning).ToArray(); + Assert.AreEqual(0, errors.Length, string.Join(Environment.NewLine, errors.Select(d => d.ToString()))); + using var stream = new MemoryStream(); + var emitted = result.Compilation.Emit(stream); + Assert.IsTrue(emitted.Success, string.Join(Environment.NewLine, emitted.Diagnostics)); + return Assembly.Load(stream.ToArray()); + } + + private static GeneratorRun Generate(string source) + { + var options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + var references = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!.Split(Path.PathSeparator) + .Select(static path => MetadataReference.CreateFromFile(path)) + .Concat(new[] { MetadataReference.CreateFromFile(typeof(LiteMapperAttribute).Assembly.Location) }); + var compilation = CSharpCompilation.Create("CycleBridgeContextTests", new[] { CSharpSyntaxTree.ParseText(source, options) }, 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, "Invalid test input: " + string.Join(Environment.NewLine, inputErrors.Select(d => d.ToString()))); + GeneratorDriver driver = CSharpGeneratorDriver.Create(new[] { new LiteMapperGenerator().AsSourceGenerator() }, parseOptions: options); + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + var result = driver.GetRunResult(); + Assert.AreEqual(0, result.Diagnostics.Count(d => d.Severity >= DiagnosticSeverity.Warning), string.Join(Environment.NewLine, result.Diagnostics)); + Assert.AreEqual(1, result.GeneratedTrees.Length); + return new GeneratorRun(output, result.GeneratedTrees.Single().GetText().ToString(), result.Diagnostics.ToArray()); + } + + private sealed class GeneratorRun + { + public GeneratorRun(Compilation compilation, string source, Diagnostic[] diagnostics) + { Compilation = compilation; Source = source; Diagnostics = diagnostics; } + public Compilation Compilation { get; } + public string Source { get; } + public Diagnostic[] Diagnostics { get; } + } + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index cdc3b8b..1fb3c12 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(4, generated.Length, "All four mapper containers must generate in " + framework + "/" + language + "."); + Assert.AreEqual(5, generated.Length, "All five mapper containers must generate in " + framework + "/" + language + "."); var executable = consumer; if (standard) @@ -157,6 +157,24 @@ public static void Run() throw new InvalidOperationException(""The configured root guard was not emitted.""); } catch (ArgumentNullException) { } + var sharedNode = new NodeSource(); + var wrapped = new WrappedSource { Envelope = new EnvelopeSource { Nodes = new[] { sharedNode, sharedNode } } }; + var wrappedTarget = WrappedCycleMapper.Map(wrapped); + if (wrappedTarget.Envelope.Nodes.Count != 2 || + ReferenceEquals(wrappedTarget.Envelope.Nodes[0], wrappedTarget.Envelope.Nodes[1])) + { + throw new InvalidOperationException(""Wrapped mapping failed.""); + } + sharedNode.Next = sharedNode; + try + { + WrappedCycleMapper.Map(wrapped); + throw new InvalidOperationException(""Wrapped cycle detection failed.""); + } + catch (LiteMapperCycleException error) when ( + error.MappingMethod == ""Map"" && error.MemberPath == ""Envelope.Nodes.Next"") + { + } var node = new NodeSource(); node.Next = node; try @@ -200,6 +218,33 @@ public sealed class Target } public sealed class ChildSource { public int Value { get; set; } } public sealed class ChildTarget { public int Value { get; set; } } +[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] +public static partial class WrappedCycleMapper +{ + public static partial WrappedTarget Map(WrappedSource source); +} + +public sealed class WrappedSource +{ + public EnvelopeSource Envelope { get; set; } = new EnvelopeSource(); +} + +public sealed class WrappedTarget +{ + public EnvelopeTarget Envelope { get; set; } = new EnvelopeTarget(); +} + +public sealed class EnvelopeSource +{ + public NodeSource[] Nodes { get; set; } = new NodeSource[0]; +} + +public sealed class EnvelopeTarget +{ + public List Nodes { get; set; } = new List(); +} + + public sealed class NodeSource { public NodeSource? Next { get; set; } } public sealed class NodeTarget { public NodeTarget? Next { get; set; } } "; diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs index 86c5489..0011b45 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -249,6 +249,25 @@ private static string CreateConsumer(string feed, string targetFramework) throw new System.InvalidOperationException(""Instance mapping failed.""); } +var sharedNode = new NodeSource(); +var wrapped = new WrappedSource { Envelope = new EnvelopeSource { Nodes = new[] { sharedNode, sharedNode } } }; +var wrappedTarget = WrappedCycleMapper.Map(wrapped); +if (wrappedTarget.Envelope.Nodes.Count != 2 || + object.ReferenceEquals(wrappedTarget.Envelope.Nodes[0], wrappedTarget.Envelope.Nodes[1])) +{ + throw new System.InvalidOperationException(""Wrapped mapping failed.""); +} +sharedNode.Next = sharedNode; +try +{ + WrappedCycleMapper.Map(wrapped); + throw new System.InvalidOperationException(""Wrapped cycle detection failed.""); +} +catch (LiteMapperCycleException error) when ( + error.MappingMethod == ""Map"" && error.MemberPath == ""Envelope.Nodes.Next"") +{ +} + var node = new NodeSource(); node.Next = node; try @@ -327,6 +346,33 @@ public sealed class FormattedChildTarget public string Value { get; set; } = string.Empty; } +[LiteMapper(ReferenceHandling = ReferenceHandling.ThrowOnCycle)] +public static partial class WrappedCycleMapper +{ + public static partial WrappedTarget Map(WrappedSource source); +} + +public sealed class WrappedSource +{ + public EnvelopeSource Envelope { get; set; } = new EnvelopeSource(); +} + +public sealed class WrappedTarget +{ + public EnvelopeTarget Envelope { get; set; } = new EnvelopeTarget(); +} + +public sealed class EnvelopeSource +{ + public NodeSource[] Nodes { get; set; } = new NodeSource[0]; +} + +public sealed class EnvelopeTarget +{ + public List Nodes { get; set; } = new List(); +} + + public sealed class NodeSource { public NodeSource? Next { get; set; }