diff --git a/CHANGELOG.md b/CHANGELOG.md index af2984e..869f003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ## Fixes +- 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) - Issue #28: IgnoreCase rejects multiple case-insensitive source matches even when one is exact; nested structural ambiguity retains LITEMAPPER1004. Exact and ExactThenIgnoreCase keep their existing exact-match priority. [#28](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/28) - Issue #27: by-name enum exceptions use stable source names and paths instead of applying `nameof` to guarded expressions; nullable enum captures retain source metadata. [#27](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/27) diff --git a/DECISIONS.md b/DECISIONS.md index 411d246..5473bc0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,12 @@ # Mammoth.LiteMapper Decision Log +## Issue #31: classify the selected dictionary key conversion (2026-10-09) + +- Context: the shared comparer predicate compared dictionary value types, so widening or structurally mapping values discarded an otherwise compatible key comparer. It also preserved comparers for same-type keys without checking whether a custom conversion was selected. +- Decision: retain a language-identity flag from the successful element-resolution stage. Dictionary construction requires that flag and matching key types, independently of value types. Earlier converter/mapping stages never claim language identity. Preserve existing concrete-source eligibility, set comparer selection, and Add insertion. +- Contract: sections 11.1, 15.7, 15.9-15.10, 19.4, and 22.12 already define these semantics. No public API or specification change is introduced; reflection and runtime comparer discovery remain absent. +- Evidence: the initial 31-case C# 9 fixture on unchanged production produced 19 failures and 12 controls, zero skips. Cases distinguish value-only conversion from same-type custom key conversion, verify all dictionary destinations, and retain collision, copying, null, helper/update, and deterministic same-driver behavior. + ## Issue #30: independently select exact HashSet capacity overloads (2026-10-09) - Context: counted set mappings always emitted a capacity argument without probing the reference API; netstandard2.0 lacks that constructor. Preserved-comparer mappings always omitted capacity, even when the combined overload exists. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 23d3c36..4ca0ce3 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Issue #31: dictionary key comparer preservation (2026-10-09) + +- Scope: preserve a concrete Dictionary source's compatible key comparer when key resolution selects language identity, independently of value conversion. Converted keys use the destination default comparer; retain Add collision semantics and existing set behavior. +- Test-first: reproduce value widening/structural conversion across concrete and interface destinations, and same-type custom key conversion; retain independent copies, empty/null controls, member/update paths, converter precedence, deterministic C# 9 output, and real package consumers. +- Implementation: carry the selected key conversion's identity classification from the existing resolver; select the dictionary comparer by key type rather than value type. No public API or specification semantics change. +- Validation: focused/full generator, runtime/integration, source and clean-package samples, supported framework/language matrix, available package/API/benchmark checks, independent review, and exact-head ordinary Windows/Linux and Roslyn CI. +- Boundary: issue #31 only, then draft PR/human review. No merge, release, tag, package publication, or workflow dispatch. AgentStack CLI is absent; approved connector fallback adds/readbacks readiness labels without issue comments or claim writes. + ## Issue #30: symbol-proven HashSet capacity constructors (2026-10-09) - Scope: probe the exact accessible HashSet capacity and capacity-plus-comparer constructors independently; use portable parameterless/comparer-only construction when unavailable. Preserve set contents, comparer selection, independent identity, and one enumeration. Do not change list or dictionary construction. diff --git a/STATUS.md b/STATUS.md index 5eda4d9..6dbee0e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,15 @@ # Mammoth.LiteMapper Status +## GitHub issue #31 dictionary comparer checkpoint (2026-10-09) + +- Base: isolated cloud worktree from develop c973aac8e457cc40a4069889aeab49d8278ccca3 after PR #44 merged. Issue #31 was the older remaining P3, with no blocked-by dependencies, sub-issues, or competing PR. Explicitly 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 C# 9 generator cases on unchanged production produced 19 failures and 12 controls, zero skips. Widened and structural values discarded key comparer identity; same-type custom key conversions incorrectly retained it. All fixture inputs compile cleanly. The normally compiled Collections sample independently failed its comparer-preservation assertion after building with zero warnings/errors. +- Implementation: carry an identity flag only from the successful language-conversion stage of key resolution. Dictionary preservation checks compatible key types rather than value types. Earlier custom converter/mapping stages remain non-identity; concrete-source eligibility, Add insertion, set comparer behavior, public API, and specification semantics remain unchanged. +- Core evidence: Release solution build has zero warnings/errors. All 904 generator tests, five runtime tests, and one integration test pass with zero failures/skips. The focused dictionary/collection/set group passes 96/96. All three source samples pass. Actual generated sample source has comparer-bearing construction for identity keys and default construction for transformed keys, with one foreach/Add loop. Independent read-only review, consumer-skill validation, and CRLF-aware diff checks pass. +- Package evidence: available packaging suite records 224 passes, two infrastructure failures, zero skips. All 12 framework/language combinations compile and execute the shared DictionaryComparerExample, including genuine netstandard2.0 references. The example covers widening/structural values and custom-key default-comparer/collision controls across Dictionary, IDictionary, and IReadOnlyDictionary destinations. Clean-package samples, usage validation, package/API checks, and benchmark smoke pass. Normal, trimmed, and Native AOT package consumers reuse the same source. +- Local limits: trimming and Native AOT fail before publish validation 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. Existing SDK 10.0.401 and runtimes 8.0.31/9.0.20/10.0.12 use workspace-local caches and temporary files with one-processor builds. Exact-head ordinary Windows/Linux and Roslyn CI evidence belongs in the draft PR. +- Boundary: issue #31 only, then human review. The previously documented nullable-intermediate enum defect and issue #32 remain outside scope. No user-computer access, other issue, merge, release, tag push, package publication, or workflow dispatch. + ## GitHub issue #30 HashSet constructor capability checkpoint (2026-10-09) - Base: isolated cloud worktree from develop 63885e1af353b45f85c28fdf2306c06409cd92bb after PR #43 merged. Issue #30 was the only remaining P2, with no blocked-by dependencies, sub-issues, or competing PRs. Authorized exec:agent and ready:agent labels were added and read back. AgentStack CLI is absent; connector fallback is used without issue comments or claim writes. diff --git a/docs/USAGE.md b/docs/USAGE.md index a82f0ce..eb4a3b2 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -708,6 +708,8 @@ LiteMapper enumerates arbitrary enumerable sources once. Capacity is preallocate The [portable set example](../samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs) maps arrays and lists to `HashSet` and `ISet`, collapsing duplicate converted values. It also verifies detached copies with preserved case-insensitive comparers. `IReadOnlySet` is included only when that interface exists. +The [dictionary comparer example](../samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs) preserves a concrete dictionary source's case-insensitive key comparer when widening `int` values to `long` or structurally mapping value objects. This applies to `Dictionary`, `IDictionary`, and `IReadOnlyDictionary` destinations. Comparer selection depends on the selected key conversion, independently of value conversion: custom key converters use the destination default comparer even when their input and output key types match. Converted-key collisions throw through normal `Add` semantics. Interface-declared dictionary sources use the default comparer because their declared contracts do not expose a comparer; LiteMapper does not discover it at runtime. + 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. Public collection mappings are allowed when the declared mapping method itself maps one supported collection shape to another supported collection shape: diff --git a/samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs b/samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs new file mode 100644 index 0000000..04742fe --- /dev/null +++ b/samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using Mammoth.LiteMapper; + +namespace Mammoth.LiteMapper.Samples.Collections +{ + public static class DictionaryComparerExample + { + public static void Run() + { + var numbers = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["A"] = 7 }; + CheckWidened(DictionaryComparerMapper.WidenToDictionary(numbers), numbers); + CheckWidened(DictionaryComparerMapper.WidenToInterface(numbers), numbers); + CheckWidened(DictionaryComparerMapper.WidenToReadOnlyDictionary(numbers), numbers); + + var values = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["A"] = new DictionaryValueSource { Value = 7 }, + }; + CheckStructural(DictionaryComparerMapper.MapToDictionary(values), values); + CheckStructural(DictionaryComparerMapper.MapToInterface(values), values); + CheckStructural(DictionaryComparerMapper.MapToReadOnlyDictionary(values), values); + + CheckConvertedKeys(ConvertedDictionaryKeyMapper.ToDictionary); + CheckConvertedKeys(ConvertedDictionaryKeyMapper.ToInterface); + CheckConvertedKeys(ConvertedDictionaryKeyMapper.ToReadOnlyDictionary); + } + + private static void CheckWidened(object mapped, Dictionary source) + { + if (!(mapped is Dictionary dictionary) || dictionary.Count != 1 || + !dictionary.TryGetValue("a", out var value) || value != 7 || + ReferenceEquals(source, mapped) || !ReferenceEquals(dictionary.Comparer, source.Comparer)) + { + throw new InvalidOperationException("Widening dictionary values must preserve the key comparer without aliasing."); + } + + dictionary["A"] = 9; + dictionary.Add("B", 2); + if (source.Count != 1 || source["A"] != 7 || dictionary.Count != 2) + { + throw new InvalidOperationException("Changing a widened dictionary must not change its source."); + } + } + + private static void CheckStructural(object mapped, Dictionary source) + { + if (!(mapped is Dictionary dictionary) || dictionary.Count != 1 || + !dictionary.TryGetValue("a", out var value) || value.Value != 7 || + ReferenceEquals(source, mapped) || ReferenceEquals(source["A"], value) || + !ReferenceEquals(dictionary.Comparer, source.Comparer)) + { + throw new InvalidOperationException("Structural dictionary values must preserve the key comparer without aliasing."); + } + + value.Value = 9; + dictionary.Add("B", new DictionaryValueTarget { Value = 2 }); + if (source.Count != 1 || source["A"].Value != 7 || dictionary.Count != 2) + { + throw new InvalidOperationException("Changing a mapped dictionary or value must not change its source."); + } + } + + private static void CheckConvertedKeys(Func, object> map) + { + var source = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["A"] = 7 }; + var mapped = map(source); + if (!(mapped is Dictionary dictionary) || dictionary.Count != 1 || + !dictionary.TryGetValue("a", out var value) || value != 7 || dictionary.ContainsKey("A") || + ReferenceEquals(source, mapped) || !ReferenceEquals(dictionary.Comparer, EqualityComparer.Default)) + { + throw new InvalidOperationException("Same-type converted keys must use the destination default comparer."); + } + + dictionary.Add("A", 9); + if (source.Count != 1 || source["A"] != 7 || dictionary.Count != 2) + { + throw new InvalidOperationException("Converted keys must allow distinct default-comparer keys without changing the source."); + } + + var colliding = new Dictionary(StringComparer.Ordinal) { ["A"] = 1, ["a"] = 2 }; + try + { + map(colliding); + } + catch (ArgumentException) + { + return; + } + + throw new InvalidOperationException("Converted dictionary key collisions must throw using Add semantics."); + } + } + + [LiteMapper] + public static partial class DictionaryComparerMapper + { + public static partial Dictionary WidenToDictionary(Dictionary source); + public static partial IDictionary WidenToInterface(Dictionary source); + public static partial IReadOnlyDictionary WidenToReadOnlyDictionary(Dictionary source); + public static partial Dictionary MapToDictionary(Dictionary source); + public static partial IDictionary MapToInterface(Dictionary source); + public static partial IReadOnlyDictionary MapToReadOnlyDictionary(Dictionary source); + } + + [LiteMapper] + public static partial class ConvertedDictionaryKeyMapper + { + public static partial Dictionary ToDictionary(Dictionary source); + public static partial IDictionary ToInterface(Dictionary source); + public static partial IReadOnlyDictionary ToReadOnlyDictionary(Dictionary source); + + [MappingConverter] + private static string NormalizeKey(string key) => key.ToLowerInvariant(); + } + + public sealed class DictionaryValueSource + { + public int Value { get; set; } + } + + public sealed class DictionaryValueTarget + { + public long Value { get; set; } + } +} diff --git a/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs b/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs index bb6c72b..ce064bb 100644 --- a/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs +++ b/samples/Mammoth.LiteMapper.Samples.Collections/Program.cs @@ -9,6 +9,7 @@ internal static class Program private static void Main() { PortableSetExample.Run(); + DictionaryComparerExample.Run(); var nullableLines = NullableLineMapper.MapNullableLines(new LineSource?[] { diff --git a/skills/mammoth-litemapper/references/mapping-rules.md b/skills/mammoth-litemapper/references/mapping-rules.md index 53761fa..6bc0912 100644 --- a/skills/mammoth-litemapper/references/mapping-rules.md +++ b/skills/mammoth-litemapper/references/mapping-rules.md @@ -108,7 +108,7 @@ Enum numeric policy applies to declared values and unmatched-name ByValue fallba Declare public top-level collection mappings explicitly, for example `public static partial List MapCustomers(Customer[] source);` inside a static mapper. Supported shapes include one-dimensional/jagged arrays, common generic sequence/list interfaces, `List`, sets, and dictionaries; `IReadOnlySet` requires the symbol in the compilation. -Mutable collections are copied; elements are not necessarily deep-cloned. Arbitrary enumerable sources are traversed once. Null elements are not silently removed. Dictionary keys and values map separately; duplicate converted keys throw through normal `Add` semantics. Compatible identity key/element mappings can preserve comparers. Do not generate custom/immutable collection construction, queues, stacks, rectangular arrays, or async enumerables. +Mutable collections are copied; elements are not necessarily deep-cloned. Arbitrary enumerable sources are traversed once. Null elements are not silently removed. Dictionary keys and values map separately; duplicate converted keys throw through normal `Add` semantics. Compatible identity element mappings can preserve set comparers. Concrete Dictionary sources preserve compatible key comparers independently of value conversion, including widening or structural values. A selected custom key converter uses the destination default comparer even for identical key types; interface-declared sources do not expose a comparer for preservation. Do not generate custom/immutable collection construction, queues, stacks, rectangular arrays, or async enumerables. Interface destinations use `T[]` for `IEnumerable`, `IReadOnlyCollection`, and `IReadOnlyList`; `List` for `ICollection` and `IList`; `HashSet` for set interfaces; and `Dictionary` for dictionary interfaces. The same result types apply to nested members and `NullCollections.Empty`. In 3.0.0, regression coverage verifies array results, mutable-list results, independent copies, one-pass enumeration, and nested/null-to-empty mappings. diff --git a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs index 340c52e..c980ad1 100644 --- a/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs +++ b/src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs @@ -1963,8 +1963,8 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, if (targetShape.IsDictionary) { - var keyConversion = ResolveElementExpression(method, sourceShape.KeyType!, targetShape.KeyType!, itemLocal + ".Key", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance, sourceValueName: "Key"); - var valueConversion = ResolveElementExpression(method, sourceShape.ElementType, targetShape.ElementType, itemLocal + ".Value", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance, sourceValueName: "Value"); + var keyConversion = ResolveElementExpression(method, sourceShape.KeyType!, targetShape.KeyType!, itemLocal + ".Key", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance, out var keyIsIdentity, sourceValueName: "Key"); + var valueConversion = ResolveElementExpression(method, sourceShape.ElementType, targetShape.ElementType, itemLocal + ".Value", null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance, out _, sourceValueName: "Value"); if (keyConversion == null || valueConversion == null) { return null; @@ -1974,7 +1974,7 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, builder.Append(ConcreteCollectionType(targetShape, targetType)); builder.Append("("); builder.Append(SourceCountExpression(sourceType, sourceShape, sourceExpression) ?? "0"); - if (CanPreserveComparer(sourceShape, targetShape, sourceType, targetType)) + if (keyIsIdentity && CanPreserveComparer(sourceShape, targetShape, sourceType, targetType)) { builder.Append(", "); builder.Append(sourceExpression); @@ -2043,7 +2043,7 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, builder.AppendLine(");"); } - var elementConversion = ResolveElementExpression(method, sourceShape.ElementType, targetShape.ElementType, itemLocal, null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance); + var elementConversion = ResolveElementExpression(method, sourceShape.ElementType, targetShape.ElementType, itemLocal, null, compilation, diagnostics, helpers, helperNames, options, location, declaredCalls, ref requiresInstance, out _); if (elementConversion == null) { return null; @@ -2083,8 +2083,9 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, return builder.ToString(); } - private static string? ResolveElementExpression(IMethodSymbol method, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, Compilation compilation, ICollection diagnostics, ImmutableArray.Builder helpers, HelperNameRegistry helperNames, EffectiveMappingOptions options, Location? location, ICollection declaredCalls, ref bool requiresInstance, string sourceValueName = "item") + private static string? ResolveElementExpression(IMethodSymbol method, ITypeSymbol sourceType, ITypeSymbol targetType, string expression, ISymbol? sourceMember, Compilation compilation, ICollection diagnostics, ImmutableArray.Builder helpers, HelperNameRegistry helperNames, EffectiveMappingOptions options, Location? location, ICollection declaredCalls, ref bool requiresInstance, out bool isIdentity, string sourceValueName = "item") { + isIdentity = false; if (ReportDuplicateVisibleDefaults(method, sourceType, targetType, compilation, diagnostics, "item", location)) { return null; @@ -2126,6 +2127,7 @@ private static bool ReportDuplicateVisibleDefaults(IMethodSymbol mappingMethod, ResolveLanguageConversion(sourceType, targetType, expression, compilation, diagnostics, location, options, isElement: true); if (languageConversion != null) { + isIdentity = compilation.ClassifyConversion(sourceType, targetType).IsIdentity; return languageConversion; } @@ -2463,12 +2465,9 @@ private static bool CanPreserveComparer(CollectionShape sourceShape, CollectionS return false; } - if (!SymbolEqualityComparer.Default.Equals(sourceShape.ElementType, targetShape.ElementType)) - { - return false; - } - - if (sourceShape.IsDictionary && !SymbolEqualityComparer.Default.Equals(sourceShape.KeyType, targetShape.KeyType)) + if (!SymbolEqualityComparer.Default.Equals( + sourceShape.IsDictionary ? sourceShape.KeyType : sourceShape.ElementType, + targetShape.IsDictionary ? targetShape.KeyType : targetShape.ElementType)) { return false; } diff --git a/tests/Mammoth.LiteMapper.Generator.Tests/DictionaryComparerConversionTests.cs b/tests/Mammoth.LiteMapper.Generator.Tests/DictionaryComparerConversionTests.cs new file mode 100644 index 0000000..c25ef32 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Generator.Tests/DictionaryComparerConversionTests.cs @@ -0,0 +1,324 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Mammoth.LiteMapper.Generator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Generator.Tests +{ + [TestClass] + public sealed class DictionaryComparerConversionTests + { + [TestMethod] + [DataRow("Dictionary", false)] + [DataRow("IDictionary", false)] + [DataRow("IReadOnlyDictionary", false)] + [DataRow("Dictionary", true)] + [DataRow("IDictionary", true)] + [DataRow("IReadOnlyDictionary", true)] + public void ValueWideningPreservesKeyComparerAndIndependentCopy(string target, bool empty) + { + var result = Run(Header + @" +[LiteMapper] public static partial class Mapper { + public static partial " + target + @" Map(Dictionary source); +} +public static class Probe { + public static bool Run() { + var source = new Dictionary(StringComparer.OrdinalIgnoreCase); + " + (empty ? "" : "source.Add(\"A\", 7);") + @" + var mapped = (Dictionary)Mapper.Map(source); + if (!ReferenceEquals(source.Comparer, mapped.Comparer) || ReferenceEquals(source, mapped) || + mapped.Count != source.Count) return false; + " + (empty ? "" : "if (!mapped.ContainsKey(\"a\") || mapped[\"a\"] != 7L) return false;") + @" + mapped.Add(""B"", 8L); + source.Add(""C"", 9); + return !source.ContainsKey(""b"") && !mapped.ContainsKey(""c""); + } +}"); + AssertValid(result); + Assert.IsTrue(Execute(result), "Value conversion must not change key equality or alias mutable dictionaries."); + StringAssert.Contains(result.Source, "new global::System.Collections.Generic.Dictionary(source.Count, source.Comparer)"); + StringAssert.Contains(result.Source, "target.Add(item.Key, item.Value)"); + } + + [TestMethod] + [DataRow("Dictionary", false)] + [DataRow("IDictionary", false)] + [DataRow("IReadOnlyDictionary", false)] + [DataRow("Dictionary", true)] + [DataRow("IReadOnlyDictionary", true)] + public void StructuralValuesPreserveKeyComparer(string target, bool nullable) + { + var suffix = nullable ? "?" : ""; + var result = Run(Header + @" +[LiteMapper] public static partial class Mapper { + public static partial " + target + " Map(Dictionary source); +} +public sealed class Source { public int Value { get; set; } } +public sealed class Target { public long Value { get; set; } } +public static class Probe { + public static bool Run() { + var value = new Source { Value = 7 }; + var source = new Dictionary(StringComparer.OrdinalIgnoreCase) { [""A""] = value }; + " + (nullable ? "source.Add(\"Null\", null);" : "") + @" + var mapped = (Dictionary)Mapper.Map(source); + return ReferenceEquals(source.Comparer, mapped.Comparer) && !ReferenceEquals(source, mapped) && + mapped.ContainsKey(""a"") && mapped[""a""]?.Value == 7L && !ReferenceEquals(value, mapped[""a""]) + " + (nullable ? "&& mapped.ContainsKey(\"null\") && mapped[\"null\"] == null" : "") + @"; + } +}"); + AssertValid(result); + Assert.IsTrue(Execute(result), "Structural value mapping must preserve key equality, including nullable values."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ValueConvertersPreserveKeyComparerAndRunOnce(bool widening) + { + var valueType = widening ? "long" : "int"; + var result = Run(Header + @" +[LiteMapper] public static partial class Mapper { + public static int Calls; + public static partial Dictionary Map(Dictionary source); + [MappingConverter] private static " + valueType + @" ConvertValue(int value) { Calls++; return value + 10; } +} +public static class Probe { + public static bool Run() { + var source = new Dictionary(StringComparer.OrdinalIgnoreCase) { [""A""] = 7, [""B""] = 9 }; + var mapped = Mapper.Map(source); + return ReferenceEquals(source.Comparer, mapped.Comparer) && mapped.ContainsKey(""a"") && + mapped[""a""] == 17 && mapped[""b""] == 19 && Mapper.Calls == 2; + } +}"); + AssertValid(result); + Assert.IsTrue(Execute(result), "Value converter selection is independent of the identity-compatible key comparer."); + } + + [TestMethod] + [DataRow("local", false)] + [DataRow("default", false)] + [DataRow("external-converter", false)] + [DataRow("external-mapping", false)] + [DataRow("local", true)] + [DataRow("default", true)] + [DataRow("external-converter", true)] + [DataRow("external-mapping", true)] + public void SameTypeKeyConvertersUseDefaultComparer(string kind, bool wideningValues) + { + var external = kind.StartsWith("external", StringComparison.Ordinal); + var attribute = kind == "default" ? "[DefaultMapping]" : kind == "external-mapping" ? "" : "[MappingConverter]"; + var converter = attribute + @" public static string ConvertKey(string value) { Calls++; return value == ""first"" ? ""A"" : ""a""; }"; + var valueType = wideningValues ? "long" : "int"; + var owner = external ? "External" : "Mapper"; + var result = Run(Header + (external ? "[UseMapper(typeof(External))]" : "") + @" +[LiteMapper] public static partial class Mapper { + public static partial Dictionary Map(Dictionary source); + " + (external ? "" : "public static int Calls; " + converter) + @" +} +" + (external ? "public static class External { public static int Calls; " + converter + " }" : "") + @" +public static class Probe { + public static bool Run() { + var source = new Dictionary(StringComparer.OrdinalIgnoreCase) { [""first""] = 7, [""second""] = 9 }; + var mapped = Mapper.Map(source); + return ReferenceEquals(mapped.Comparer, EqualityComparer.Default) && + mapped.Count == 2 && mapped[""A""] == 7 && mapped[""a""] == 9 && " + owner + @".Calls == 2; + } +}"); + AssertValid(result); + Assert.IsFalse(result.Source.Contains("source.Comparer", StringComparison.Ordinal)); + Assert.IsTrue(Execute(result), "A selected key converter is not identity conversion merely because its input and output types match."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void ConvertedKeyCollisionsThrowThroughAdd(bool sameType) + { + var keyType = sameType ? "string" : "int"; + var result = Run(Header + @" +[LiteMapper] public static partial class Mapper { + public static int Calls; + public static partial Dictionary<" + keyType + @", long> Map(Dictionary source); + [MappingConverter] private static " + keyType + @" ConvertKey(string value) { Calls++; return " + (sameType ? "\"same\"" : "1") + @"; } +} +public static class Probe { + public static bool Run() { + var source = new Dictionary(StringComparer.OrdinalIgnoreCase) { [""first""] = 7, [""second""] = 9 }; + try { Mapper.Map(source); return false; } + catch (ArgumentException) { return Mapper.Calls == 2 && source.Count == 2; } + } +}"); + AssertValid(result); + StringAssert.Contains(result.Source, "target.Add("); + Assert.IsFalse(result.Source.Contains("source.Comparer", StringComparison.Ordinal)); + Assert.IsTrue(Execute(result), "Converted-key collisions must throw rather than silently overwrite or discard an entry."); + } + + [TestMethod] + public void DifferentKeyTypesUseDefaultComparer() + { + var result = Run(Header + @" +[LiteMapper] public static partial class Mapper { + public static partial Dictionary Map(Dictionary source); +} +public sealed class KeyComparer : IEqualityComparer { + public bool Equals(int a, int b) => Math.Abs(a) == Math.Abs(b); + public int GetHashCode(int value) => Math.Abs(value); +} +public static class Probe { + public static bool Run() { + var source = new Dictionary(new KeyComparer()) { [1] = 7 }; + var mapped = Mapper.Map(source); + return ReferenceEquals(mapped.Comparer, EqualityComparer.Default) && + mapped[1] == 7 && !mapped.ContainsKey(-1); + } +}"); + AssertValid(result); + Assert.IsFalse(result.Source.Contains("source.Comparer", StringComparison.Ordinal)); + Assert.IsTrue(Execute(result)); + } + + [TestMethod] + [DataRow("IDictionary")] + [DataRow("IReadOnlyDictionary")] + public void InterfaceSourcesDoNotDiscoverRuntimeComparers(string sourceType) + { + var result = Run(Header + @" +[LiteMapper] public static partial class Mapper { + public static partial Dictionary Map(" + sourceType + @" source); +} +public static class Probe { + public static bool Run() { + var source = new Dictionary(StringComparer.OrdinalIgnoreCase) { [""A""] = 7 }; + var mapped = Mapper.Map(source); + return ReferenceEquals(mapped.Comparer, EqualityComparer.Default) && mapped[""A""] == 7 && !mapped.ContainsKey(""a""); + } +}"); + AssertValid(result); + Assert.IsFalse(result.Source.Contains("source.Comparer", StringComparison.Ordinal)); + Assert.IsTrue(Execute(result)); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void MemberMappingAndUpdatePreserveComparerWithoutAliasing(bool update) + { + var result = Run(Header + @" +public sealed class Source { + private readonly Dictionary values = new Dictionary(StringComparer.OrdinalIgnoreCase) { [""A""] = 7 }; + public int Reads; + public Dictionary Values { get { Reads++; return values; } } +} +public sealed class Target { public IReadOnlyDictionary Values { get; set; } = new Dictionary(); } +[LiteMapper] public static partial class Mapper { + public static partial " + (update ? "void Apply(Source source, Target target);" : "Target Map(Source source);") + @" +} +public static class Probe { + public static bool Run() { + var source = new Source(); + var target = new Target(); + var original = target.Values; + " + (update ? "Mapper.Apply(source, target);" : "target = Mapper.Map(source);") + @" + var reads = source.Reads; + var mapped = (Dictionary)target.Values; + return reads == 1 && !ReferenceEquals(original, target.Values) && + ReferenceEquals(source.Values.Comparer, mapped.Comparer) && mapped[""a""] == 7; + } +}"); + AssertValid(result); + Assert.IsTrue(Execute(result), "Dictionary helper paths must preserve key comparers and replace writable target collections."); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public void NullCollectionPolicyRemainsIndependent(bool empty) + { + var result = Run(Header + @" +[LiteMapper(NullCollections = NullCollectionStrategy." + (empty ? "Empty" : "Preserve") + @")] +public static partial class Mapper { + public static partial Dictionary" + (empty ? "" : "?") + @" Map(Dictionary? source); +} +public static class Probe { + public static bool Run() { + var mapped = Mapper.Map(null); + return " + (empty ? "mapped.Count == 0 && ReferenceEquals(mapped.Comparer, EqualityComparer.Default)" : "mapped == null") + @"; + } +}"); + AssertValid(result); + Assert.IsTrue(Execute(result)); + } + + [TestMethod] + public void SameDriverKeyConversionChangeMatchesFreshDeterministicOutput() + { + var source = Header + @" +[LiteMapper] public static partial class Mapper { + public static partial Dictionary Map(Dictionary source); + CONVERTER +}"; + var firstCompilation = CreateCompilation(source.Replace("CONVERTER", "", StringComparison.Ordinal)); + var first = Run(firstCompilation, CreateDriver()); + var secondCompilation = firstCompilation.ReplaceSyntaxTree(firstCompilation.SyntaxTrees.Single(), + CSharpSyntaxTree.ParseText(source.Replace("CONVERTER", "[MappingConverter] private static string Change(string key) => key.ToUpperInvariant();", StringComparison.Ordinal), ParseOptions, "Mapper.cs")); + var second = Run(secondCompilation, first.Driver); + var fresh = Run(secondCompilation, CreateDriver()); + AssertValid(first); + AssertValid(second); + AssertValid(fresh); + StringAssert.Contains(first.Source, "source.Comparer"); + Assert.IsFalse(second.Source.Contains("source.Comparer", StringComparison.Ordinal)); + Assert.AreEqual(fresh.Source, second.Source); + Assert.AreEqual(second.Source, Run(secondCompilation, second.Driver).Source); + } + + private const string Header = "using System; using System.Collections.Generic; using Mammoth.LiteMapper;\n"; + private static readonly CSharpParseOptions ParseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp9); + + private static CSharpCompilation CreateCompilation(string source) + { + var references = AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!.ToString()!.Split(Path.PathSeparator) + .Where(static path => path.EndsWith("System.Private.CoreLib.dll", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("System.Runtime.dll", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("System.Collections.dll", StringComparison.OrdinalIgnoreCase) || + path.EndsWith("netstandard.dll", StringComparison.OrdinalIgnoreCase)) + .Select(static path => MetadataReference.CreateFromFile(path)) + .Append(MetadataReference.CreateFromFile(typeof(LiteMapperAttribute).Assembly.Location)); + var compilation = CSharpCompilation.Create("DictionaryTests_" + Guid.NewGuid().ToString("N"), + new[] { CSharpSyntaxTree.ParseText(source, ParseOptions, "Mapper.cs") }, references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable)); + var inputErrors = compilation.GetDiagnostics().Where(static diagnostic => diagnostic.Severity == DiagnosticSeverity.Error && diagnostic.Id != "CS8795").ToArray(); + Assert.AreEqual(0, inputErrors.Length, string.Join(Environment.NewLine, inputErrors.Select(static diagnostic => diagnostic.ToString()))); + return compilation; + } + + private static GeneratorDriver CreateDriver() => CSharpGeneratorDriver.Create( + new[] { new LiteMapperGenerator().AsSourceGenerator() }, parseOptions: ParseOptions, + driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true)); + private static Result Run(string source) => Run(CreateCompilation(source), CreateDriver()); + private static Result Run(CSharpCompilation compilation, GeneratorDriver driver) + { + driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _); + return new Result(driver, output, string.Join(Environment.NewLine, driver.GetRunResult().GeneratedTrees.Select(static tree => tree.ToString()))); + } + private static void AssertValid(Result result) + { + var diagnostics = result.Driver.GetRunResult().Diagnostics.Concat(result.Compilation.GetDiagnostics()) + .Where(static diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning).ToArray(); + Assert.AreEqual(0, diagnostics.Length, string.Join(Environment.NewLine, diagnostics.Select(static diagnostic => diagnostic.ToString()))); + } + private static bool Execute(Result result) + { + using var stream = new MemoryStream(); + var emit = result.Compilation.Emit(stream); + Assert.IsTrue(emit.Success, string.Join(Environment.NewLine, emit.Diagnostics)); + return (bool)Assembly.Load(stream.ToArray()).GetType("Probe")!.GetMethod("Run")!.Invoke(null, null)!; + } + private sealed record Result(GeneratorDriver Driver, Compilation Compilation, string Source); + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs index 9144e1f..89bac15 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/ConsumerMatrixTests.cs @@ -50,13 +50,15 @@ public void PackageMappingsCompileAndExecuteAcrossFrameworksAndLanguages(string File.WriteAllText(Path.Combine(consumer, "Consumer.cs"), Fixture.Replace("EXPECTED_RUNTIME_MAJOR", runtimeMajor, StringComparison.Ordinal)); File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs"), Path.Combine(consumer, "PortableSetExample.cs")); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs"), + Path.Combine(consumer, "DictionaryComparerExample.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(9, generated.Length, "All nine mapper containers must generate in " + framework + "/" + language + "."); + Assert.AreEqual(11, generated.Length, "All eleven 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(" + @@ -186,6 +188,7 @@ public static class MatrixFixture public static void Run() { Mammoth.LiteMapper.Samples.Collections.PortableSetExample.Run(); + Mammoth.LiteMapper.Samples.Collections.DictionaryComparerExample.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 378175f..ed98fbd 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Milestone14PackagingAndAotTests.cs @@ -224,6 +224,7 @@ private static string CreateConsumer(string feed, string targetFramework) using Mammoth.LiteMapper; Mammoth.LiteMapper.Samples.Collections.PortableSetExample.Run(); +Mammoth.LiteMapper.Samples.Collections.DictionaryComparerExample.Run(); EnumPathExample.Run(); PatchUpdaterExample.Run(); @@ -479,6 +480,8 @@ public sealed class PatchUpdaterChildTarget { public int Value { get; set; } } "); File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/PortableSetExample.cs"), Path.Combine(directory, "PortableSetExample.cs")); + File.Copy(Repository.Path("samples/Mammoth.LiteMapper.Samples.Collections/DictionaryComparerExample.cs"), + Path.Combine(directory, "DictionaryComparerExample.cs")); return directory; }