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..192bcbc 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,133 @@ 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 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 { + Name = x is PolymorphicNestedType1Source + ? ((PolymorphicNestedType1Source)x).Name!.Value + : x is PolymorphicNestedType2Source + ? ((PolymorphicNestedType2Source)x).OtherName!.Value + : string.Empty + }); + } + } + + 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(); @@ -2042,6 +2175,78 @@ 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_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( 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); }