diff --git a/CHANGELOG.md b/CHANGELOG.md index ae27bc7..359d99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- Issue #48: configured enum paths preserve missing reference intermediates as null for nullable enum destinations, including constructor-bound and existing-target mappings. By-name errors, by-value conversion, single evaluation, patch skipping, and converter precedence are preserved. [#48](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/48) - Issue #32: recursive cycle paths retain compact immutable segments and format the full path only when a cycle is detected, removing quadratic path-prefix allocations while preserving exception metadata and first-edge allocation behavior. [#32](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/32) - Issue #31: dictionary mappings preserve compatible key comparers independently of value conversion. Keys transformed by custom converters use the destination default comparer, even when key types match. [#31](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/31) - Issue #30: set mappings probe exact accessible HashSet capacity constructors and use portable fallbacks when absent, including on netstandard2.0. Capacity-plus-comparer support is detected independently. [#30](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/30) diff --git a/DECISIONS.md b/DECISIONS.md index fa2a622..12e6a74 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Decision Log +## Issue #48: effective nullability at the enum boundary (2026-10-09) + +- Context: a configured source path can evaluate to null even when its terminal enum symbol is non-nullable. Enum lowering previously considered only Nullable symbol wrappers, so a missing reference intermediate entered by-name unknown-value handling or a by-value unwrapping cast. +- Decision: carry effective expression nullability into the selected built-in enum stage, separately from symbol unwrapping, and reuse its existing single-evaluation nullable wrapper. Recursive underlying enum lowering clears the path flag. Earlier converter/language resolution and existing mismatch/patch guards retain their ordering. +- Required patch construction: mark successful built-in enum conversions so a required nullable enum initializer can re-lower the original nullable source path instead of substituting it into an unwrapped patch expression. This does not reselect converters or change the normal existing-target assignment. A nullable-input converter control continues to receive null. +- Contract: sections 9.4, 11.1/11.4, 12.4, 13, 14.3, and 16.1/16.3 already require these semantics. No public API, diagnostic contract, or specification change is introduced. Nullable intermediate traversal resolution is separate; this repair covers supported nullable-reference paths, including subsequent value/tuple fields. +- Evidence: the completed initial 70-case enum fixture has 28 failures and 42 controls on unchanged production. Four additional required-initializer cases fail the first candidate while the explicit converter control passes. All 184 related focused cases pass after the scoped repair; full evidence belongs in STATUS and the PR. + ## Issue #47: configured nullable updater captures (2026-10-09) - Non-semantic repair under specification sections 9.4, 11.4, 14.4, 16.1 and 16.3. A patch pattern capture unwraps Nullable; both automatic and configured source paths must restore the selected source type at invocation so C# overload binding matches generator resolution. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index c1a7670..0befef9 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #48: nullable enum source-path intermediates (2026-10-09) + +- Scope: preserve missing configured source-path intermediates as null for nullable enum targets. Retain known/unknown enum behavior, stable exception metadata, single evaluation, patch skipping, update order, and converter/null-mismatch precedence. No public API or specification semantics change. +- Test-first: exercise creation, constructor binding, updates, reference/value/tuple intermediates, by-name/by-value strategies, nullable-leaf/scalar controls, changing getters, and C# 9 deterministic warning-free output. Add a shared normally compiled and package-consumer example. +- Implementation: carry effective path nullability into enum lowering only after higher-precedence conversions and existing mismatch guards. Reuse the existing nullable enum capture and wrapper. +- Validation: focused/full generator, runtime/integration, source and clean-package samples, supported framework/language consumers, available packaging checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI. Disclose unavailable local stages. +- Boundary: issue #48 only, then draft PR/human review. No merge, release, tag, package publication, workflow dispatch, or user-computer access. AgentStack CLI is absent; authorized connector fallback added/read back readiness labels without issue comments or claim writes. Dependencies, sub-issues and competing PRs are absent. + ## Issue #47: configured nullable updater overload binding (2026-10-09) - Scope: preserve the selected nullable value-type updater overload after capturing a configured direct or dotted patch source path. Retain single evaluation, null skipping, target identity, and sequential update behavior. No public API or specification semantics change. diff --git a/STATUS.md b/STATUS.md index 58140ed..54589d1 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,16 @@ # Mammoth.LiteMapper Status +## GitHub issue #48 nullable enum source-path checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop 6e70b78a742f4fdabfd60472917ae0290bac8dbe after PR #49 merged. Issue #48 has no blocked-by dependencies, sub-issues or competing PRs. Authorized exec:agent and ready:agent labels were added/read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. +- Red-first: the final 70-case enum fixture yielded 28 failures and 42 passing controls on unchanged production, zero skips. The normally compiled Basic example independently failed for a missing Child. Four required nullable enum initializer cases then failed the first candidate while the explicit nullable-input converter control passed. +- Implementation: pass effective path nullability into built-in enum lowering separately from Nullable symbol unwrapping, reusing its single-evaluation wrapper. Required nullable enum patch construction re-lowers the original nullable path only after selection of the built-in enum stage. Converter precedence, mismatch guards, existing-target patch behavior, update order, public API and specification semantics are unchanged. +- Core evidence: final Release solution build has zero warnings/errors. All 989 generator, five runtime and one integration tests pass with zero failures/skips. The related focused group passes 184/184; all three source samples pass. Independent read-only review, consumer-skill validation, solution-path consistency and CRLF-aware diff checks pass. +- CI fixture recovery: first-head Linux Roslyn jobs passed all 989 cases, but each Windows host exposed five converter controls whose member insertion matched LF-only text. The fixture now inserts at its unique MapProperty attribute instead of matching a newline sequence. All 75 enum cases pass when the test file is compiled with CRLF, preserving every assertion and restoring the original LF line-ending format afterward. Production code is unchanged by this portability correction. +- Package evidence: available packaging suite records 224 passes, two infrastructure failures, zero skips. All 12 framework/language consumers, clean-package samples, usage validation, package/API checks and benchmark smoke pass. The shared NullableEnumPathExample executes in source, clean-package and matrix consumers, including genuine netstandard2.0/C#9 references. Its actual generated package source was inspected for single-evaluation nullable enum wrappers and stable path metadata. Normal/trim/AOT consumers include the same example. +- Local limits: trimming and Native AOT fail before publish behavior is established at ILLink ComputeManagedAssemblies task-host creation, with MSB4216 / Unix-domain SocketException 13. PowerShell and clang are absent; 49 PowerShell-dependent cases were not run locally. No warning, assertion, dependency, prerequisite, credential or security setting was weakened. Official SDK 10.0.401 and runtimes 8.0.31/9.0.20/10.0.12 use workspace-local caches/temporary files and one-processor builds. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR. +- Coverage and boundary: create/constructor/update/patch, by-name/by-value, nullable-leaf/scalar controls, multiple nullable references, subsequent value/tuple fields, changing getters, flags, checked/unchecked conversion, stable unknown-value metadata, converter precedence, deterministic C#9 output and C#11 required initialization. Nullable value-type intermediate traversal that fails earlier source-path resolution is separate and unchanged. Issue #48 only, then human review; no user-computer access, merge, release, tag, package publication or workflow dispatch. + ## GitHub issue #47 configured nullable updater checkpoint (2026-10-09) - Base: isolated cloud worktree from develop 9c48bfd59fe586097ca18c4289c85f03e93e30bc after PR #46 merged. New P2 issue #47 has no blocked-by dependencies, sub-issues or competing PRs. Authorized exec:agent and ready:agent labels were added/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 64fcd79..a0b6ca7 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -882,7 +882,7 @@ public static partial class EnumPathMapper For this non-null enum target, `Ready = 1` maps by name to `Ready = 10`, a missing `Child` follows the Throw policy, and an unknown value throws `ArgumentOutOfRangeException` with `ParamName` equal to `Child.State` and the original enum in `ActualValue`. Root mappings retain their declared parameter name; direct members retain their source-member name. Exception metadata does not cause another getter evaluation. -Known limitation: a nullable intermediate with a non-nullable enum leaf mapped to a nullable enum target still throws when the intermediate is null. Null preservation for this specific configured-path shape is not yet supported correctly. +For a nullable enum target, a missing reference intermediate preserves null even when the enum leaf is non-nullable. The Basic sample's `NullableEnumPathExample.cs` demonstrates creation, constructor binding, existing-target updates, and patch skipping for `Child.State`. By-name mapping converts `Ready = 1` to `Ready = 10` and retains the exception metadata above for unknown non-null values; by-value mapping preserves null and converts the underlying value according to the enum numeric policy. Explicit converters still take precedence and a nullable-input converter receives a missing path as null. ## Recursive mappings and cycle detection diff --git a/samples/Mammoth.LiteMapper.Samples.Basic/NullableEnumPathExample.cs b/samples/Mammoth.LiteMapper.Samples.Basic/NullableEnumPathExample.cs new file mode 100644 index 0000000..883cdd1 --- /dev/null +++ b/samples/Mammoth.LiteMapper.Samples.Basic/NullableEnumPathExample.cs @@ -0,0 +1,120 @@ +using System; + +namespace Mammoth.LiteMapper.Samples.Basic +{ + public static class NullableEnumPathExample + { + public static void Run() + { + foreach (var map in new Func[] + { + source => NullableEnumPathMapper.Create(source).State, + source => NullableEnumPathMapper.Construct(source).State, + Update, + }) + { + Check(map, null, null); + Check(map, NullableEnumInputState.Ready, NullableEnumOutputState.Ready); + Check(map, (NullableEnumInputState)99, null, throws: true); + } + + Check(source => NullableEnumPathMapper.ByValue(source).State, null, null); + Check(source => NullableEnumPathMapper.ByValue(source).State, + NullableEnumInputState.Ready, (NullableEnumOutputState)1); + Check(source => NullableEnumPathMapper.ByValue(source).State, + (NullableEnumInputState)99, (NullableEnumOutputState)99); + + var target = new NullableEnumPathTarget { State = (NullableEnumOutputState)77 }; + Func patch = source => + { + NullableEnumPathMapper.Patch(source, target); + return target.State; + }; + Check(patch, null, (NullableEnumOutputState)77); + Check(patch, NullableEnumInputState.Ready, NullableEnumOutputState.Ready); + Check(patch, (NullableEnumInputState)99, null, throws: true); + if (target.State != NullableEnumOutputState.Ready) + throw new InvalidOperationException("A failed enum conversion must not replace the patched value."); + } + + private static NullableEnumOutputState? Update(NullableEnumPathSource source) + { + var target = new NullableEnumPathTarget { State = (NullableEnumOutputState)77 }; + if (!ReferenceEquals(target, NullableEnumPathMapper.Apply(source, target))) + throw new InvalidOperationException("An enum update must preserve the destination reference."); + return target.State; + } + + private static void Check( + Func map, + NullableEnumInputState? state, + NullableEnumOutputState? expected, + bool throws = false) + { + var child = state.HasValue ? new NullableEnumPathChild(state.Value) : null; + var source = new NullableEnumPathSource(child); + try + { + var actual = map(source); + if (throws || actual != expected) + throw new InvalidOperationException("Nullable enum paths must preserve null and the selected enum strategy."); + } + catch (ArgumentOutOfRangeException error) when ( + throws && error.ParamName == "Child.State" && + error.ActualValue is NullableEnumInputState value && value == (NullableEnumInputState)99) + { + } + + if (source.Reads != 1 || (child != null && child.Reads != 1)) + throw new InvalidOperationException("Each enum source-path getter must be evaluated once."); + } + } + + [LiteMapper] + public static partial class NullableEnumPathMapper + { + [MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))] + public static partial NullableEnumPathTarget Create(NullableEnumPathSource source); + + [MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathConstructorTarget.State))] + public static partial NullableEnumPathConstructorTarget Construct(NullableEnumPathSource source); + + [MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))] + public static partial NullableEnumPathTarget Apply(NullableEnumPathSource source, NullableEnumPathTarget target); + + [MappingOptions(EnumMapping = EnumMappingStrategy.ByValue)] + [MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))] + public static partial NullableEnumPathTarget ByValue(NullableEnumPathSource source); + + [MappingOptions(IgnoreNullSourceMembers = OptionState.Enabled)] + [MapProperty(Source = "Child.State", Target = nameof(NullableEnumPathTarget.State))] + public static partial void Patch(NullableEnumPathSource source, NullableEnumPathTarget target); + } + + public enum NullableEnumInputState { Ready = 1 } + public enum NullableEnumOutputState { Ready = 10 } + + public sealed class NullableEnumPathSource + { + private readonly NullableEnumPathChild? child; + public NullableEnumPathSource(NullableEnumPathChild? child) { this.child = child; } + public int Reads { get; private set; } + public NullableEnumPathChild? Child { get { Reads++; return Reads == 1 ? child : null; } } + } + + public sealed class NullableEnumPathChild + { + private readonly NullableEnumInputState state; + public NullableEnumPathChild(NullableEnumInputState state) { this.state = state; } + public int Reads { get; private set; } + public NullableEnumInputState State { get { Reads++; return Reads == 1 ? state : (NullableEnumInputState)98; } } + } + + public sealed class NullableEnumPathTarget { public NullableEnumOutputState? State { get; set; } } + + public sealed class NullableEnumPathConstructorTarget + { + public NullableEnumPathConstructorTarget(NullableEnumOutputState? state) { State = state; } + public NullableEnumOutputState? State { get; } + } +} diff --git a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs index 949f71e..d0f1f0b 100644 --- a/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Basic/Program.cs @@ -10,6 +10,7 @@ private static void Main() { LazyCyclePathExample.Run(); ConfiguredUpdaterOverloadExample.Run(); + NullableEnumPathExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); var staticTarget = StaticMapper.Map(new StaticSource diff --git a/skills/mammoth-litemapper/references/mapping-rules.md b/skills/mammoth-litemapper/references/mapping-rules.md index 2293ff6..7836508 100644 --- a/skills/mammoth-litemapper/references/mapping-rules.md +++ b/skills/mammoth-litemapper/references/mapping-rules.md @@ -116,6 +116,8 @@ An unknown-count enumerable-to-array mapping may allocate a temporary growing bu Enum defaults are `EnumMappingStrategy.ByName`, `UnmatchedEnumValuePolicy.Error`, and `EnumNumericConversion.Checked`. Names match exactly. `Throw` permits unmatched names with runtime `ArgumentOutOfRangeException`; by-value conversion uses the independent enum numeric policy. Domain-specific enum semantics belong in a converter. +In development revisions containing the nullable enum-path repair, a missing reference intermediate in `Child.State` maps to null when the destination enum is nullable, even when `State` is non-nullable. By-name unknown values still throw with the configured path and original value; by-value preserves null before numeric conversion. A nullable-input explicit converter still receives null. Verify the installed revision before relying on this shape; the published 3.0.0 generator may fail to compile the guarded path. + `ReferenceHandling.ThrowOnCycle` detects active-path reference cycles in recursive generated mappings and throws `LiteMapperCycleException`. Shared references on separate branches are not cycles and identity is not preserved. Without tracking, recursive runtime cycles can overflow the stack. The exception exposes `SourceType`, `DestinationType`, `MappingMethod`, and `MemberPath`. Recursive declared mappings share one tracker. For an `A -> B -> A` component implemented by generated `MapA` and `MapB`, the cycle reports `MapA` at `B.A`. Value helpers forward tracker state but are not entered or boxed; a finite class-to-struct-to-class path maps normally. diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 2dad358..619498e 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -873,7 +873,22 @@ private static MappingModel CreateUpdateMappingModel(IMethodSymbol method, Compi initializationSource = "(" + initializationSource + " ?? throw new global::System.InvalidOperationException(\"Source member path '" + memberPath + "' was null.\"))"; } - initializationExpression = conversion.Expression.Replace(sourcePathCaptureName, initializationSource); + if (conversion.IsEnumMapping && IsMaybeNull(GetMemberType(targetMember))) + { + // Construction consumes the original nullable path, not the guarded patch capture. + var initialization = ResolveEnumMapping(method, selected?.Type ?? GetMemberType(match.Member), + GetMemberType(targetMember), initializationSource, match.Member, memberPath, memberPath, + diagnostics, targetMember.Locations.FirstOrDefault() ?? location, options, sourceExpressionMayBeNull: true); + if (initialization == null) + { + continue; + } + initializationExpression = initialization.Expression; + } + else + { + initializationExpression = conversion.Expression.Replace(sourcePathCaptureName, initializationSource); + } } assignments.Add(new AssignmentModel(targetMember.Name, conversion.Expression, guard, initializeDuringConstruction, initializationExpression: initializationExpression, declaredMapping: conversion.DeclaredMapping, requiresInstance: conversion.RequiresInstance)); } @@ -1156,7 +1171,7 @@ private static HashSet ParseMemberNames(IMethodSymbol method, string att return null; } - var enumMapping = ResolveEnumMapping(mappingMethod, sourceType, targetType, expression, sourceMember, sourcePath?.Expression ?? targetMember.Name, sourcePath?.Expression ?? sourceMember!.Name, diagnostics, location, options); + var enumMapping = ResolveEnumMapping(mappingMethod, sourceType, targetType, expression, sourceMember, sourcePath?.Expression ?? targetMember.Name, sourcePath?.Expression ?? sourceMember!.Name, diagnostics, location, options, sourceExpressionMayBeNull: sourceExpressionMayBeNull); if (enumMapping != null || diagnostics.Count != languageDiagnosticCount) { return enumMapping; @@ -1305,11 +1320,12 @@ private static bool IsNumericType(ITypeSymbol type) type.SpecialType == SpecialType.System_IntPtr || type.SpecialType == SpecialType.System_UIntPtr; } - private static ConversionModel? ResolveEnumMapping(IMethodSymbol mappingMethod, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, string targetName, string sourceValueName, ICollection diagnostics, Location? location, EffectiveMappingOptions options, bool isRoot = false, bool isElement = false) + private static ConversionModel? ResolveEnumMapping(IMethodSymbol mappingMethod, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, string targetName, string sourceValueName, ICollection diagnostics, Location? location, EffectiveMappingOptions options, bool isRoot = false, bool isElement = false, bool sourceExpressionMayBeNull = false) { var nullableSource = sourceType is INamedTypeSymbol sourceValue && sourceValue.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; var nullableTarget = targetType is INamedTypeSymbol targetValue && targetValue.OriginalDefinition.SpecialType == SpecialType.System_Nullable_T; - if (nullableSource || nullableTarget) + var nullableExpression = nullableSource || sourceExpressionMayBeNull; + if (nullableExpression || nullableTarget) { var underlyingSource = nullableSource ? ((INamedTypeSymbol)sourceType).TypeArguments[0] : sourceType; var underlyingTarget = nullableTarget ? ((INamedTypeSymbol)targetType).TypeArguments[0] : targetType; @@ -1318,7 +1334,7 @@ private static bool IsNumericType(ITypeSymbol type) return null; } - if (nullableSource && !nullableTarget && options.NullableMismatch == NullableMismatchPolicyError && !(options.IgnoreNullSourceMembers && !isRoot && !isElement)) + if (nullableExpression && !nullableTarget && options.NullableMismatch == NullableMismatchPolicyError && !(options.IgnoreNullSourceMembers && !isRoot && !isElement)) { diagnostics.Add(Diagnostic.Create(isElement ? Diagnostics.NullableElementMismatch : Diagnostics.NullableToNonNullable, location, targetName)); return null; @@ -1330,14 +1346,14 @@ private static bool IsNumericType(ITypeSymbol type) valueName += "_"; } - var underlyingMapping = ResolveEnumMapping(mappingMethod, underlyingSource, underlyingTarget, nullableSource ? valueName : expression, sourceMember, targetName, sourceValueName, diagnostics, location, options); + var underlyingMapping = ResolveEnumMapping(mappingMethod, underlyingSource, underlyingTarget, nullableExpression ? valueName : expression, sourceMember, targetName, sourceValueName, diagnostics, location, options); if (underlyingMapping == null) { return null; } var mappedExpression = nullableTarget ? "(" + DisplayType(targetType) + ")(" + underlyingMapping.Expression + ")" : underlyingMapping.Expression; - if (nullableSource) + if (nullableExpression) { var nullResult = nullableTarget ? "(" + DisplayType(targetType) + ")null" : isRoot ? "throw new global::System.ArgumentNullException(nameof(" + expression + "))" @@ -1345,7 +1361,7 @@ private static bool IsNumericType(ITypeSymbol type) mappedExpression = expression + " switch { " + DisplayType(underlyingSource) + " " + valueName + " => " + mappedExpression + ", _ => " + nullResult + " }"; } - return new ConversionModel(mappedExpression, sourceMember, potentiallyNull: nullableSource && nullableTarget, targetName, nullCheckExpression: null); + return new ConversionModel(mappedExpression, sourceMember, potentiallyNull: nullableExpression && nullableTarget, targetName, nullCheckExpression: null, isEnumMapping: true); } if (!(sourceType is INamedTypeSymbol sourceEnum) || !(targetType is INamedTypeSymbol targetEnum) || @@ -1360,7 +1376,7 @@ private static bool IsNumericType(ITypeSymbol type) { ReportEnumValueOverflow(sourceEnum, targetEnum, diagnostics, location); } - return new ConversionModel(EnumUnderlyingConversion(expression, sourceEnum, targetEnum, options), sourceMember, potentiallyNull: false, targetName, nullCheckExpression: null); + return new ConversionModel(EnumUnderlyingConversion(expression, sourceEnum, targetEnum, options), sourceMember, potentiallyNull: false, targetName, nullCheckExpression: null, isEnumMapping: true); } var members = GetEnumMembers(sourceEnum); @@ -1477,7 +1493,7 @@ private static bool IsNumericType(ITypeSymbol type) var captureValue = !(SyntaxFactory.ParseExpression(expression) is IdentifierNameSyntax); arms = arms.Concat(new[] { (captureValue ? "var " + enumValueName : "_") + " => throw new global::System.ArgumentOutOfRangeException(" + Microsoft.CodeAnalysis.CSharp.SymbolDisplay.FormatLiteral(sourceValueName, quote: true) + ", " + (captureValue ? enumValueName : expression) + ", \"Unmapped enum value.\")" }); - return new ConversionModel(expression + " switch\n {\n " + string.Join(",\n ", arms) + "\n }", sourceMember, potentiallyNull: false, targetName, nullCheckExpression: null); + return new ConversionModel(expression + " switch\n {\n " + string.Join(",\n ", arms) + "\n }", sourceMember, potentiallyNull: false, targetName, nullCheckExpression: null, isEnumMapping: true); } private static string? CreateFlagsCompositeArm(INamedTypeSymbol sourceEnum, INamedTypeSymbol targetEnum, string valueName, IEnumerable mappedMembers) @@ -5052,7 +5068,7 @@ public SourcePathModel(string expression, ISymbol sourceMember, ITypeSymbol type private sealed class ConversionModel { - public ConversionModel(string expression, ISymbol? sourceMember, bool potentiallyNull, string memberPath, string? nullCheckExpression, bool requiresNonNullSourceExpression = false, IMethodSymbol? declaredMapping = null, bool requiresInstance = false) + public ConversionModel(string expression, ISymbol? sourceMember, bool potentiallyNull, string memberPath, string? nullCheckExpression, bool requiresNonNullSourceExpression = false, IMethodSymbol? declaredMapping = null, bool requiresInstance = false, bool isEnumMapping = false) { Expression = expression; SourceMember = sourceMember; @@ -5062,6 +5078,7 @@ public ConversionModel(string expression, ISymbol? sourceMember, bool potentiall RequiresNonNullSourceExpression = requiresNonNullSourceExpression; DeclaredMapping = declaredMapping; RequiresInstance = requiresInstance; + IsEnumMapping = isEnumMapping; } public string Expression { get; } @@ -5079,6 +5096,8 @@ public ConversionModel(string expression, ISymbol? sourceMember, bool potentiall public IMethodSymbol? DeclaredMapping { get; } public bool RequiresInstance { get; } + + public bool IsEnumMapping { get; } } private sealed class EffectiveMappingOptions diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/EnumDiagnosticNameTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/EnumDiagnosticNameTests.cs index 152dbf6..7e09bdb 100644 --- a/tests/Mammoth.LiteMapper.Generator.Tests/EnumDiagnosticNameTests.cs +++ b/tests/Mammoth.LiteMapper.Generator.Tests/EnumDiagnosticNameTests.cs @@ -101,11 +101,15 @@ public void ErrorPolicyStillRejectsNullableConfiguredPaths(bool nullableLeaf) } [TestMethod] - public void NullableIntermediateToNullableTargetCompilesWithStableExceptionMetadata() + [DataRow("create")] + [DataRow("constructor")] + [DataRow("update")] + [DataRow("patch")] + [DataRow("flags")] + public void NullableIntermediateToNullableTargetPreservesNullAndStableExceptionMetadata(string mode) { - // Runtime null preservation for a non-null leaf is a separate, pre-existing defect. - var result = Generate(PathFixture("create", true, false, true)); - AssertCompiles(result); + var result = Generate(PathFixture(mode, true, false, true)); + AssertRuns(result); StringAssert.Contains(result.Result.GeneratedTrees.Single().ToString(), "ArgumentOutOfRangeException(\"Child.State\""); } @@ -192,6 +196,274 @@ public void ExceptionMetadataIsDeterministic() second.Result.GeneratedTrees.Select(tree => tree.ToString()).ToArray()); } + [TestMethod] + [DataRow("create")] + [DataRow("constructor")] + [DataRow("update")] + [DataRow("patch")] + public void ByValueNullablePathsPreserveNullAndUnknownNumericValues(string mode) + { + AssertRuns(Generate(ByValueFixture(mode))); + } + + [TestMethod] + [DataRow("create", false)] + [DataRow("constructor", false)] + [DataRow("update", false)] + [DataRow("patch", false)] + [DataRow("create", true)] + [DataRow("constructor", true)] + [DataRow("update", true)] + [DataRow("patch", true)] + public void ValueIntermediatesAfterNullableReferencesPreserveNullAndReadEachGetterOnce(string mode, bool byValue) + { + var source = (byValue ? ByValueFixture(mode) : PathFixture(mode, true, false, true)) + .Replace("Child.State", "Child.Value.State") + .Replace("public From State { get { Reads++; return state; } }", + "public Payload Value { get { Reads++; return new Payload { State = state }; } }") + + "public struct Payload { public From State; }"; + AssertRuns(Generate(source)); + } + + [TestMethod] + [DataRow("create", false)] + [DataRow("constructor", false)] + [DataRow("update", false)] + [DataRow("create", true)] + [DataRow("constructor", true)] + [DataRow("update", true)] + public void TupleFieldsAfterNullableIntermediatesPreserveNull(string mode, bool byValue) + { + var source = (byValue ? ByValueFixture(mode) : PathFixture(mode, true, false, true)) + .Replace("Child.State", "Child.Values.Item1") + .Replace("public From State { get { Reads++; return state; } }", + "public (From, int) Values { get { Reads++; return (state, 1); } }"); + AssertRuns(Generate(source)); + } + + [TestMethod] + [DataRow("create")] + [DataRow("update")] + public void ChangingIntermediateGettersUseTheFirstValueOnce(string mode) + { + var source = PathFixture(mode, true, false, true) + .Replace("Reads++; return child;", "Reads++; return Reads == 1 ? child : new Child(From.Other);") + .Replace("Reads++; return state;", "Reads++; return Reads == 1 ? state : From.Other;"); + AssertRuns(Generate(source)); + } + + [TestMethod] + public void NullableIntermediateOutputRemainsDeterministicWithCaptureNameCollision() + { + var source = PathFixture("create", true, false, true) + .Replace("Source source", "Source __nullableEnumValue") + .Replace("Mapper.Map(source)", "Mapper.Map(__nullableEnumValue)") + .Replace("var source =", "var __nullableEnumValue =") + .Replace("source.Reads", "__nullableEnumValue.Reads"); + var first = Generate(source); + AssertRuns(first); + CollectionAssert.AreEqual(first.Result.GeneratedTrees.Select(tree => tree.ToString()).ToArray(), + Generate(source).Result.GeneratedTrees.Select(tree => tree.ToString()).ToArray()); + } + + [TestMethod] + [DataRow("create", false)] + [DataRow("update", false)] + [DataRow("create", true)] + [DataRow("update", true)] + public void ByValueNullablePathsKeepCheckedAndUncheckedOverflow(string mode, bool uncheckedConversion) + { + var source = ByValueFixture(mode).Replace("public enum To", "public enum To : byte") + .Replace("(From)99", "(From)300") + .Replace("if (scenario == 1) return mapped.State == (To)99;", uncheckedConversion + ? "if (scenario == 1) return mapped.State == (To)44;" : "if (scenario == 1) return false;") + .Replace("catch (ArgumentOutOfRangeException ex)", "catch (OverflowException) { return scenario == 1; } catch (ArgumentOutOfRangeException ex)"); + if (uncheckedConversion) + source = source.Replace("EnumMapping = EnumMappingStrategy.ByValue,", "EnumMapping = EnumMappingStrategy.ByValue, EnumNumericConversion = EnumNumericConversion.Unchecked,") + .Replace("catch (OverflowException) { return scenario == 1; }", "catch (OverflowException) { return false; }"); + AssertRuns(Generate(source)); + } + + [TestMethod] + [DataRow("create", true)] + [DataRow("constructor", true)] + [DataRow("update", true)] + [DataRow("create", false)] + public void NullableInputConverterStillReceivesMissingPathAndOverridesEnumStrategy(string mode, bool explicitUse) + { + var source = PathFixture(mode, true, false, true) + .Replace("[MapProperty(", "public static int Calls;\n " + + (explicitUse ? "" : "[MappingConverter] ") + + "private static To? Choose(From? value) { Calls++; return value == From.Ready ? To.Ready : To.Other; }\n [MapProperty(") + .Replace("if (scenario == 1) return false;", "if (scenario == 1) return mapped.State == To.Other;") + .Replace("return mapped.State == null;", "return mapped.State == To.Other;") + .Replace("var source = new Source(child);", "Mapper.Calls = 0; var source = new Source(child);") + .Replace("source.Reads != 1", "Mapper.Calls != 1 || source.Reads != 1"); + if (explicitUse) + source = source.Replace("Target = nameof(Target.State))", "Target = nameof(Target.State), Use = nameof(Choose))"); + AssertRuns(Generate(source)); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void MultipleNullableIntermediatesPreserveEveryMissingBoundary(bool byValue) + { + var source = Preamble + @" +[LiteMapper] +public static partial class Mapper +{ + [MapProperty(Source = ""Outer.Inner.State"", Target = nameof(Target.State))] + public static partial Target Map(Source source); +} +public sealed class Source +{ + public Outer? Value; + public int Reads; + public Outer? Outer { get { Reads++; return Value; } } +} +public sealed class Outer +{ + public Inner? Value; + public int Reads; + public Inner? Inner { get { Reads++; return Value; } } +} +public sealed class Inner +{ + public From Value; + public int Reads; + public From State { get { Reads++; return Value; } } +} +public sealed class Target { public To? State { get; set; } } +public static class Probe +{ + public static bool Run() + { + var missing = new Source(); + if (Mapper.Map(missing).State != null || missing.Reads != 1) return false; + var outer = new Outer(); + missing = new Source { Value = outer }; + if (Mapper.Map(missing).State != null || missing.Reads != 1 || outer.Reads != 1) return false; + var inner = new Inner { Value = From.Ready }; + outer = new Outer { Value = inner }; + var present = new Source { Value = outer }; + if (Mapper.Map(present).State != To.Ready || present.Reads != 1 || outer.Reads != 1 || inner.Reads != 1) return false; + inner = new Inner { Value = (From)99 }; + outer = new Outer { Value = inner }; + var unknown = new Source { Value = outer }; + try { Mapper.Map(unknown); return false; } + catch (ArgumentOutOfRangeException ex) + { + return ex.ParamName == ""Outer.Inner.State"" && ex.ActualValue is From value && value == (From)99 && + unknown.Reads == 1 && outer.Reads == 1 && inner.Reads == 1; + } + } +}"; + if (byValue) + source = source.Replace("[LiteMapper]", "[LiteMapper(EnumMapping = EnumMappingStrategy.ByValue)]") + .Replace("!= To.Ready", "!= (To)From.Ready") + .Replace("Mapper.Map(unknown); return false;", "return Mapper.Map(unknown).State == (To)99 && unknown.Reads == 1 && outer.Reads == 1 && inner.Reads == 1;"); + AssertRuns(Generate(source)); + } + + [TestMethod] + [DataRow("create")] + [DataRow("update")] + public void ScalarPathNullPreservationIsUnchanged(string mode) + { + var source = PathFixture(mode, true, false, true) + .Replace("public enum From { None = 0, Ready = 1, Other = 2 }", "") + .Replace("public enum To { None = 0, Ready = 4, Other = 8 }", "") + .Replace("From.Ready", "1").Replace("From.Other", "2") + .Replace("To.Ready", "1").Replace("To.Other", "2") + .Replace("From", "int").Replace("To?", "int?") + .Replace("if (scenario == 1) return false;", "if (scenario == 1) return mapped.State == 99;"); + AssertRuns(Generate(source)); + } + + [TestMethod] + public void EnumPathFailureKeepsEarlierAssignmentsAndDoesNotReadLaterMembers() + { + AssertRuns(Generate(Preamble + @" +[LiteMapper] +public static partial class Mapper +{ + [MapProperty(Source = ""Child.State"", Target = nameof(Target.B))] + public static partial void Map(Source source, Target destination); +} +public sealed class Source +{ + public int A { get { Probe.Events += ""getA;""; return 7; } } + public Child? Child { get { Probe.Events += ""getChild;""; return new Child(); } } + public int Z { get { Probe.Events += ""getZ;""; return 9; } } +} +public sealed class Child { public From State { get { Probe.Events += ""getState;""; return (From)99; } } } +public sealed class Target +{ + private int a; + public int A { get { return a; } set { a = value; Probe.Events += ""setA;""; } } + public To? B { get; set; } = To.Other; + public int Z { get; set; } = -1; +} +public static class Probe +{ + public static string Events = """"; + public static bool Run() + { + var target = new Target(); + try { Mapper.Map(new Source(), target); return false; } + catch (ArgumentOutOfRangeException ex) + { + return target.A == 7 && target.B == To.Other && target.Z == -1 && + Events == ""getA;setA;getChild;getState;"" && ex.ParamName == ""Child.State"" && + ex.ActualValue is From value && value == (From)99; + } + } +}")); + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(true, false)] + [DataRow(false, true)] + [DataRow(true, true)] + public void PatchCreationInitializesRequiredNullableEnumFromMissingPath(bool byValue, bool nullableLeaf) + { + var source = (byValue ? ByValueFixture("patch") : PathFixture("patch", true, false, true)) + .Replace("Target destination)", "Target? destination)") + .Replace("public To? State { get; set; }", "public required To? State { get; set; }") + .Replace("Mapper.Map(source, destination)", "Mapper.Map(source, null)") + .Replace("return object.ReferenceEquals(mapped, destination) && mapped.State == To.Other;", "return mapped.State == null;"); + if (nullableLeaf) + source = source.Replace("private readonly From state;", "private readonly From? state;") + .Replace("public Child(From state)", "public Child(From? state)") + .Replace("public From State", "public From? State") + .Replace(" && Check(null, 2)", " && Check(null, 2) && Check(new Child(null), 2)"); + AssertRuns(Generate(source, LanguageVersion.CSharp11)); + } + + [TestMethod] + public void PatchCreationStillInvokesExplicitNullableEnumConverterForNull() + { + var source = PathFixture("patch", true, false, true) + .Replace("Target destination)", "Target? destination)") + .Replace("public To? State { get; set; }", "public required To? State { get; set; }") + .Replace("Mapper.Map(source, destination)", "Mapper.Map(source, null)") + .Replace("Target = nameof(Target.State))", "Target = nameof(Target.State), Use = nameof(Choose))") + .Replace("[MapProperty(", "private static To? Choose(From? value) => value == From.Ready ? To.Ready : To.Other;\n [MapProperty(") + .Replace("if (scenario == 1) return false;", "if (scenario == 1) return mapped.State == To.Other;") + .Replace("return object.ReferenceEquals(mapped, destination) && mapped.State == To.Other;", "return mapped.State == To.Other;"); + AssertRuns(Generate(source, LanguageVersion.CSharp11)); + } + + private static string ByValueFixture(string mode) + { + return PathFixture(mode, true, false, true) + .Replace("[LiteMapper(", "[LiteMapper(EnumMapping = EnumMappingStrategy.ByValue, ") + .Replace("mapped.State == To.Ready", "mapped.State == (To)From.Ready") + .Replace("if (scenario == 1) return false;", "if (scenario == 1) return mapped.State == (To)99;"); + } + private const string Preamble = "using System; using Mammoth.LiteMapper; public enum From { None = 0, Ready = 1, Other = 2 } public enum To { None = 0, Ready = 4, Other = 8 } "; private static string PathFixture(string mode, bool nullableChild, bool nullableLeaf, bool nullableTarget) @@ -266,12 +538,12 @@ public static bool Run() => Check(new Child(From.Ready), 0) && Check(new Child(( }"; } - private static (GeneratorDriverRunResult Result, Compilation Compilation) Generate(string source) + private static (GeneratorDriverRunResult Result, Compilation Compilation) Generate(string source, LanguageVersion languageVersion = 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 parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(languageVersion); var compilation = CSharpCompilation.Create("EnumDiagnosticNameTests", new[] { CSharpSyntaxTree.ParseText(source, parseOptions) }, references, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); var inputErrors = compilation.GetDiagnostics().Where(d => d.Severity == DiagnosticSeverity.Error && d.Id != "CS8795").ToArray(); diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index e21e6d7..9be4f32 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs @@ -56,13 +56,15 @@ public void PackageMappingsCompileAndExecuteAcrossFrameworksAndLanguages(string Path.Combine(consumer, "LazyCyclePathExample.cs")); File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Basic/ConfiguredUpdaterOverloadExample.cs"), Path.Combine(consumer, "ConfiguredUpdaterOverloadExample.cs")); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Basic/NullableEnumPathExample.cs"), + Path.Combine(consumer, "NullableEnumPathExample.cs")); PackageSources(packageFeed, "https://api.nuget.org/v3/index.json") .Save(Path.Combine(directory, "NuGet.Config")); 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(13, generated.Length, "All thirteen mapper containers must generate in " + framework + "/" + language + "."); + Assert.AreEqual(14, generated.Length, "All fourteen mapper containers must generate in " + framework + "/" + language + "."); var setSource = File.ReadAllText(generated.Single(static file => Path.GetFileName(file).StartsWith( "Mammoth.LiteMapper.Samples.Collections.PortableSetMapper.", StringComparison.Ordinal))); StringAssert.Contains(setSource, "new global::System.Collections.Generic.HashSet(" + @@ -195,6 +197,7 @@ public static void Run() Mammoth.LiteMapper.Samples.Collections.DictionaryComparerExample.Run(); Mammoth.LiteMapper.Samples.Basic.LazyCyclePathExample.Run(); Mammoth.LiteMapper.Samples.Basic.ConfiguredUpdaterOverloadExample.Run(); + Mammoth.LiteMapper.Samples.Basic.NullableEnumPathExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); if (Environment.Version.Major != EXPECTED_RUNTIME_MAJOR) throw new InvalidOperationException(""Wrong runtime was used.""); diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs index c1345eb..9d9df0c 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -227,6 +227,7 @@ private static string CreateConsumer(string feed, string targetFramework) Mammoth.LiteMapper.Samples.Collections.DictionaryComparerExample.Run(); Mammoth.LiteMapper.Samples.Basic.LazyCyclePathExample.Run(); Mammoth.LiteMapper.Samples.Basic.ConfiguredUpdaterOverloadExample.Run(); +Mammoth.LiteMapper.Samples.Basic.NullableEnumPathExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); @@ -488,6 +489,8 @@ public sealed class PatchUpdaterChildTarget { public int Value { get; set; } } Path.Combine(directory, "LazyCyclePathExample.cs")); File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Basic/ConfiguredUpdaterOverloadExample.cs"), Path.Combine(directory, "ConfiguredUpdaterOverloadExample.cs")); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Basic/NullableEnumPathExample.cs"), + Path.Combine(directory, "NullableEnumPathExample.cs")); return directory; }