Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 8 additions & 0 deletions IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
11 changes: 11 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
8 changes: 8 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LineTarget?> MapNullableLines(LineSource?[] source);
}
```

Supported collection mapping includes:
Expand All @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions samples/Mammoth.LiteMapper.Samples.Collections/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<LineTarget?> MapNullableLines(LineSource?[] source);
}

public sealed class OrderSource
{
public int Id { get; set; }
Expand Down
17 changes: 16 additions & 1 deletion src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 + ")";
}

Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading