From 61740631a4bdbd148af4a839c54550253d62466b Mon Sep 17 00:00:00 2001 From: Benjamin Vettori Date: Mon, 20 Apr 2026 22:33:10 +0200 Subject: [PATCH 1/2] Add runtime expression logging Add runtime expression logging for debug reasons --- .../MapifyEfCoreProjectionTests.UseMap.cs | 40 ++++++++ Mapify.NET.Tests/MapifyInstanceTests.cs | 95 ++++++++++++++++++- Mapify/Mapify.Runtime.Diagnostics.cs | 28 ++++++ Mapify/Mapify.Runtime.Execution.cs | 14 +++ 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 Mapify/Mapify.Runtime.Diagnostics.cs diff --git a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.UseMap.cs b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.UseMap.cs index 94dfc12..5807d51 100644 --- a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.UseMap.cs +++ b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.UseMap.cs @@ -338,4 +338,44 @@ public void InstanceMapify_Map_ShouldFallbackToBaseMap_ForProxyLikeVirtualListsL Assert.All(items, item => Assert.NotNull(item)); Assert.All(items, item => Assert.True(item.Price > 0)); } + + [Fact] + public void InstanceMapify_Map_ShouldHandleLazyLoadedProxyPolymorphicChildren_WithSiblingCastProfileShape() { + var options = new DbContextOptionsBuilder() + .UseLazyLoadingProxies() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + + using (var seed = new EfCoreMapifyContext(options)) { + seed.BillsWithVirtualListBlocks.Add(new EfCoreBillWithVirtualListBlocks { + Blocks = [ + new EfCoreVirtualListBlock { + CostItems = [ + new EfCoreVirtualListCostItemType1 { Price = 10m }, + new EfCoreVirtualListCostItemType2 { TotalPrice = 25m } + ] + } + ] + }); + + seed.SaveChanges(); + } + + using var db = new EfCoreMapifyContext(options); + + var loaded = db.BillsWithVirtualListBlocks.Single(); + + var mapify = new Mapify([ + new EfCoreVirtualListCostItemProfile(), + new EfCoreVirtualListBlockProfile(), + new EfCoreVirtualListBillProfile() + ]); + + var mapped = mapify.Map(loaded); + + var items = mapped.Blocks.Single().CostItems.ToArray(); + Assert.Equal(2, items.Length); + Assert.All(items, item => Assert.NotNull(item)); + Assert.Equal(new[] { 10m, 25m }, items.Select(x => x.Price).OrderBy(x => x).ToArray()); + } } diff --git a/Mapify.NET.Tests/MapifyInstanceTests.cs b/Mapify.NET.Tests/MapifyInstanceTests.cs index 5e32abe..dde0980 100644 --- a/Mapify.NET.Tests/MapifyInstanceTests.cs +++ b/Mapify.NET.Tests/MapifyInstanceTests.cs @@ -579,14 +579,20 @@ private class NullSafeEnumerableBranchTarget { private abstract class PolymorphicCostItemSource { } - private sealed class PolymorphicCostItemType1Source : PolymorphicCostItemSource { + private class PolymorphicCostItemType1Source : PolymorphicCostItemSource { public decimal Price { get; set; } } - private sealed class PolymorphicCostItemType2Source : PolymorphicCostItemSource { + private class PolymorphicCostItemType2Source : PolymorphicCostItemSource { public decimal TotalPrice { get; set; } } + private sealed class PolymorphicCostItemType1ProxySource : PolymorphicCostItemType1Source { + } + + private sealed class PolymorphicCostItemType2ProxySource : PolymorphicCostItemType2Source { + } + private class PolymorphicBillSource { public IEnumerable? CostItems { get; set; } } @@ -1335,6 +1341,55 @@ protected override void Configure() { } } + private class PolymorphicSiblingCastProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new PolymorphicCostItemTarget { + Price = x is PolymorphicCostItemType1Source + ? ((PolymorphicCostItemType1Source)x).Price + : x is PolymorphicCostItemType2Source + ? ((PolymorphicCostItemType2Source)x).TotalPrice + : 0m + }); + } + } + + private class PolymorphicNestedName { + public string Value { get; set; } = string.Empty; + } + + private abstract class PolymorphicNestedItemSource { + } + + private class PolymorphicNestedType1Source : PolymorphicNestedItemSource { + public PolymorphicNestedName? Name { get; set; } + } + + private class PolymorphicNestedType2Source : PolymorphicNestedItemSource { + public PolymorphicNestedName? OtherName { get; set; } + } + + private sealed class PolymorphicNestedType1ProxySource : PolymorphicNestedType1Source { + } + + private sealed class PolymorphicNestedType2ProxySource : PolymorphicNestedType2Source { + } + + private class PolymorphicNestedTarget { + public string Name { get; set; } = string.Empty; + } + + private class PolymorphicNestedSiblingCastProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new PolymorphicNestedTarget { + Name = x is PolymorphicNestedType1Source + ? ((PolymorphicNestedType1Source)x).Name!.Value + : x is PolymorphicNestedType2Source + ? ((PolymorphicNestedType2Source)x).OtherName!.Value + : string.Empty + }); + } + } + [Fact] public void Map_ToNewObject_ShouldWorkLikeStaticMapper() { var mapify = new Mapify(); @@ -2042,6 +2097,42 @@ public void Map_ProjectToShouldMapPolymorphicItems_WhenElementMapUsesTypeChecks( Assert.Equal([10m, 25m], prices); } + [Fact] + public void Map_ShouldHandleProxyLikePolymorphicSiblings_WithGuardedSiblingCasts() { + var mapify = new Mapify(new PolymorphicSiblingCastProfile()); + + var mapped1 = mapify.Map( + new PolymorphicCostItemType1ProxySource { Price = 10m } + ); + + var mapped2 = mapify.Map( + new PolymorphicCostItemType2ProxySource { TotalPrice = 25m } + ); + + Assert.Equal(10m, mapped1.Price); + Assert.Equal(25m, mapped2.Price); + } + + [Fact] + public void Map_ShouldHandleProxyLikePolymorphicSiblings_WithNestedGuardedSiblingCasts() { + var mapify = new Mapify(new PolymorphicNestedSiblingCastProfile()); + + var mapped1 = mapify.Map( + new PolymorphicNestedType1ProxySource { + Name = new PolymorphicNestedName { Value = "A" } + } + ); + + var mapped2 = mapify.Map( + new PolymorphicNestedType2ProxySource { + OtherName = new PolymorphicNestedName { Value = "B" } + } + ); + + Assert.Equal("A", mapped1.Name); + Assert.Equal("B", mapped2.Name); + } + [Fact] public void Map_ShouldBuildTransitiveDependencies_WhenProfilesAreUnordered() { // Registration order is reverse dependency order: root -> middle -> leaf. diff --git a/Mapify/Mapify.Runtime.Diagnostics.cs b/Mapify/Mapify.Runtime.Diagnostics.cs new file mode 100644 index 0000000..4bd3126 --- /dev/null +++ b/Mapify/Mapify.Runtime.Diagnostics.cs @@ -0,0 +1,28 @@ +using System.Linq.Expressions; + +namespace Mapify.NET; + +public partial class Mapify { + private const string _runtimeExpressionDebugEnvVar = "MAPIFY_DEBUG_RUNTIME_EXPRESSION"; + + private static bool IsRuntimeExpressionDebugEnabled() { + var value = Environment.GetEnvironmentVariable(_runtimeExpressionDebugEnvVar); + return string.Equals(value, "1", StringComparison.Ordinal) + || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase) + || string.Equals(value, "yes", StringComparison.OrdinalIgnoreCase); + } + + private static void LogRuntimeExpression( + string? name, + Expression> expression, + bool fromCache + ) { + if (!IsRuntimeExpressionDebugEnabled()) { + return; + } + + var mapName = name ?? ""; + Console.WriteLine($"[Mapify Runtime Expression] fromCache={fromCache} source={typeof(TSource).FullName} target={typeof(TTarget).FullName} name={mapName}"); + Console.WriteLine(expression.ToString()); + } +} diff --git a/Mapify/Mapify.Runtime.Execution.cs b/Mapify/Mapify.Runtime.Execution.cs index 54f36fc..15dc2bd 100644 --- a/Mapify/Mapify.Runtime.Execution.cs +++ b/Mapify/Mapify.Runtime.Execution.cs @@ -129,10 +129,16 @@ public TTarget Map(TSource source, string name) { var key = new MapKey(typeof(TSource), typeof(TTarget), name); if (_compiledMapToNewCache.TryGetValue(key, out var map)) { + if (IsRuntimeExpressionDebugEnabled()) { + var debugExpression = GetRequiredRuntimeMap(name, _emptyParameters); + LogRuntimeExpression(name, debugExpression, fromCache: true); + } + return ((Func)map).Invoke(source); } var expression = GetRequiredRuntimeMap(name, _emptyParameters); + LogRuntimeExpression(name, expression, fromCache: false); var compiled = expression.Compile(); _compiledMapToNewCache[key] = compiled; return compiled.Invoke(source); @@ -156,6 +162,7 @@ public TTarget Map(TSource source, string name, IReadOnlyDicti ValidateRuntimeParameters(parameters); var expression = GetRequiredRuntimeMap(name, parameters); + LogRuntimeExpression(name, expression, fromCache: false); var compiled = expression.Compile(); return compiled.Invoke(source); } @@ -171,10 +178,16 @@ public TTarget Map(TSource source, string name, IReadOnlyDicti public TTarget Map(TSource source) { var key = new MapKey(typeof(TSource), typeof(TTarget), null); if (_compiledMapToNewCache.TryGetValue(key, out var map)) { + if (IsRuntimeExpressionDebugEnabled()) { + var debugExpression = GetRequiredRuntimeMap(null, _emptyParameters); + LogRuntimeExpression(null, debugExpression, fromCache: true); + } + return ((Func)map).Invoke(source); } var expression = GetRequiredRuntimeMap(null, _emptyParameters); + LogRuntimeExpression(null, expression, fromCache: false); var compiled = expression.Compile(); _compiledMapToNewCache[key] = compiled; return compiled.Invoke(source); @@ -193,6 +206,7 @@ public TTarget Map(TSource source, IReadOnlyDictionary(null, parameters); + LogRuntimeExpression(null, expression, fromCache: false); var compiled = expression.Compile(); return compiled.Invoke(source); } From 8f4faa12da093f85c077b815892134e8671dd5eb Mon Sep 17 00:00:00 2001 From: Benjamin Vettori Date: Mon, 20 Apr 2026 22:53:04 +0200 Subject: [PATCH 2/2] Improve null-safe handling for polymorphic sibling casts and conditional conversions Ensures that null-safety guards are correctly applied to expressions where a conversion (cast) wraps a conditional expression. This specifically fixes issues when mapping polymorphic types using discriminators or type checks, allowing member access within those branches to be safely guarded even when proxy types or explicit casts are involved. --- Mapify.NET.Tests/MapifyInstanceTests.cs | 114 ++++++++++++++++++++++++ Mapify/Mapify.Engine.NullSafety.cs | 13 +++ 2 files changed, 127 insertions(+) diff --git a/Mapify.NET.Tests/MapifyInstanceTests.cs b/Mapify.NET.Tests/MapifyInstanceTests.cs index dde0980..192bcbc 100644 --- a/Mapify.NET.Tests/MapifyInstanceTests.cs +++ b/Mapify.NET.Tests/MapifyInstanceTests.cs @@ -1378,6 +1378,59 @@ private class PolymorphicNestedTarget { public string Name { get; set; } = string.Empty; } + private enum PolymorphicIndicator { + Default = 0, + Custom = 1 + } + + private class PolymorphicIndicatorDetail { + public PolymorphicIndicator Indicator { get; set; } + } + + private abstract class PolymorphicIndicatorItemSource { + } + + private class PolymorphicIndicatorType1Source : PolymorphicIndicatorItemSource { + } + + private class PolymorphicIndicatorType2Source : PolymorphicIndicatorItemSource { + public PolymorphicIndicatorDetail? Detail { get; set; } + } + + private sealed class PolymorphicIndicatorType1ProxySource : PolymorphicIndicatorType1Source { + } + + private sealed class PolymorphicIndicatorType2ProxySource : PolymorphicIndicatorType2Source { + } + + private class PolymorphicIndicatorTarget { + public PolymorphicIndicator? Indicator { get; set; } + } + + private abstract class PolymorphicDiscriminatorItemSource { + public string Kind { get; set; } = string.Empty; + } + + private class PolymorphicDiscriminatorType1Source : PolymorphicDiscriminatorItemSource { + public PolymorphicNestedName? Name { get; set; } + } + + private class PolymorphicDiscriminatorType2Source : PolymorphicDiscriminatorItemSource { + public PolymorphicNestedName? OtherName { get; set; } + public PolymorphicIndicatorDetail? Detail { get; set; } + } + + private sealed class PolymorphicDiscriminatorType1ProxySource : PolymorphicDiscriminatorType1Source { + } + + private sealed class PolymorphicDiscriminatorType2ProxySource : PolymorphicDiscriminatorType2Source { + } + + private class PolymorphicDiscriminatorTarget { + public string Name { get; set; } = string.Empty; + public PolymorphicIndicator? Indicator { get; set; } + } + private class PolymorphicNestedSiblingCastProfile : MapifyProfile { protected override void Configure() { CreateMap(x => new PolymorphicNestedTarget { @@ -1390,6 +1443,31 @@ protected override void Configure() { } } + private class PolymorphicIndicatorSiblingCastProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new PolymorphicIndicatorTarget { + Indicator = x is PolymorphicIndicatorType2Source + ? ((PolymorphicIndicatorType2Source)x).Detail!.Indicator + : PolymorphicIndicator.Default + }); + } + } + + private class PolymorphicDiscriminatorSiblingCastProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new PolymorphicDiscriminatorTarget { + Name = x.Kind == "Type1" + ? ((PolymorphicDiscriminatorType1Source)x).Name!.Value + : x.Kind == "Type2" + ? ((PolymorphicDiscriminatorType2Source)x).OtherName!.Value + : string.Empty, + Indicator = x.Kind == "Type2" + ? ((PolymorphicDiscriminatorType2Source)x).Detail!.Indicator + : PolymorphicIndicator.Default + }); + } + } + [Fact] public void Map_ToNewObject_ShouldWorkLikeStaticMapper() { var mapify = new Mapify(); @@ -2133,6 +2211,42 @@ public void Map_ShouldHandleProxyLikePolymorphicSiblings_WithNestedGuardedSiblin Assert.Equal("B", mapped2.Name); } + [Fact] + public void Map_ShouldNotEvaluateSiblingCast_ForConvertWrappedConditional_OnProxyType() { + var mapify = new Mapify(new PolymorphicIndicatorSiblingCastProfile()); + + var mapped = mapify.Map( + new PolymorphicIndicatorType1ProxySource() + ); + + Assert.Equal(PolymorphicIndicator.Default, mapped.Indicator); + } + + [Fact] + public void Map_ShouldHandleProxyLikePolymorphicSiblings_WithStringDiscriminatorGuards() { + var mapify = new Mapify(new PolymorphicDiscriminatorSiblingCastProfile()); + + var mapped1 = mapify.Map( + new PolymorphicDiscriminatorType1ProxySource { + Kind = "Type1", + Name = new PolymorphicNestedName { Value = "A" } + } + ); + + var mapped2 = mapify.Map( + new PolymorphicDiscriminatorType2ProxySource { + Kind = "Type2", + OtherName = new PolymorphicNestedName { Value = "B" }, + Detail = new PolymorphicIndicatorDetail { Indicator = PolymorphicIndicator.Custom } + } + ); + + Assert.Equal("A", mapped1.Name); + Assert.Equal(PolymorphicIndicator.Default, mapped1.Indicator); + Assert.Equal("B", mapped2.Name); + Assert.Equal(PolymorphicIndicator.Custom, mapped2.Indicator); + } + [Fact] public void Map_ShouldBuildTransitiveDependencies_WhenProfilesAreUnordered() { // Registration order is reverse dependency order: root -> middle -> leaf. diff --git a/Mapify/Mapify.Engine.NullSafety.cs b/Mapify/Mapify.Engine.NullSafety.cs index 0b73570..f9d4428 100644 --- a/Mapify/Mapify.Engine.NullSafety.cs +++ b/Mapify/Mapify.Engine.NullSafety.cs @@ -22,6 +22,19 @@ private static Expression ApplyNestedNullSafetyCore(Expression expression, Expre return ApplyNestedNullSafetyCore(normalized, fallback); } + if (expression is UnaryExpression unaryExpression + && (unaryExpression.NodeType == ExpressionType.Convert || unaryExpression.NodeType == ExpressionType.ConvertChecked) + && unaryExpression.Operand is ConditionalExpression) { + var operandFallback = AdaptFallbackToType(fallback, unaryExpression.Operand.Type); + var guardedOperand = ApplyNestedNullSafetyCore(unaryExpression.Operand, operandFallback); + + if (guardedOperand.Type == unaryExpression.Type) { + return guardedOperand; + } + + return Expression.MakeUnary(unaryExpression.NodeType, guardedOperand, unaryExpression.Type, unaryExpression.Method); + } + if (expression is ConditionalExpression conditionalExpression) { var guardedTest = ApplyNestedNullSafetyToBooleanExpression(conditionalExpression.Test); var guardedIfTrue = ApplyNestedNullSafetyCore(