diff --git a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestModels.cs b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestModels.cs index f677545..6195dac 100644 --- a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestModels.cs +++ b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestModels.cs @@ -6,6 +6,7 @@ public partial class MapifyEfCoreProjectionTests { private sealed class EfCoreMapifyContext(DbContextOptions options) : DbContext(options) { public DbSet People => Set(); public DbSet Addresses => Set(); + public DbSet Streets => Set(); public DbSet Phones => Set(); public DbSet RecursiveNodes => Set(); public DbSet ProjectionIgnoreEntities => Set(); @@ -42,6 +43,13 @@ private sealed class EfCorePerson { private sealed class EfCoreAddress { public int Id { get; set; } public string City { get; set; } = string.Empty; + public int? StreetId { get; set; } + public EfCoreStreet? Street { get; set; } + } + + private sealed class EfCoreStreet { + public int Id { get; set; } + public int Number { get; set; } } private sealed class EfCorePhone { @@ -117,4 +125,12 @@ private sealed class EfCoreProjectionIgnoreDto { private sealed class EfCorePersonRuntimeParameterDto { public int AdjustedId { get; set; } } + + private sealed class EfCorePersonStreetNumberDto { + public int StreetNumber { get; set; } + } + + private sealed class EfCorePersonStreetNullableNumberDto { + public int? StreetNumber { get; set; } + } } diff --git a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestProfiles.cs b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestProfiles.cs index eb6b8b4..9d23424 100644 --- a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestProfiles.cs +++ b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestProfiles.cs @@ -132,6 +132,22 @@ protected override void Configure() { } } + private sealed class EfCorePersonStreetNumberProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new EfCorePersonStreetNumberDto { + StreetNumber = x.HomeAddress.Street!.Number + }); + } + } + + private sealed class EfCorePersonStreetNullableNumberProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new EfCorePersonStreetNullableNumberDto { + StreetNumber = x.HomeAddress.Street!.Number + }); + } + } + private sealed class EfCoreRecursiveNodeDefaultDepthProfile : MapifyProfile { protected override void Configure() { CreateMap(x => new EfCoreRecursiveNodeDto { diff --git a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ProjectTo.cs b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ProjectTo.cs index 3881159..6892592 100644 --- a/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ProjectTo.cs +++ b/Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ProjectTo.cs @@ -124,6 +124,65 @@ public void InstanceMapify_ProjectToWithParameters_ShouldWorkInEfCoreProjection( Assert.Equal(new[] { 51, 52 }, result.Select(x => x.AdjustedId).ToArray()); } + [Fact] + public void InstanceMapify_ProjectTo_ShouldApplyNestedNullFallback_ForNullableAndNonNullableTargets() { + var options = new DbContextOptionsBuilder() + .UseSqlite("Data Source=:memory:") + .Options; + + using var db = new EfCoreMapifyContext(options); + db.Database.OpenConnection(); + db.Database.EnsureCreated(); + + db.People.AddRange( + new EfCorePerson { + FirstName = "Has", + LastName = "Street", + HomeAddress = new EfCoreAddress { + City = "Paris", + Street = new EfCoreStreet { Number = 77 } + } + }, + new EfCorePerson { + FirstName = "No", + LastName = "Street", + HomeAddress = new EfCoreAddress { + City = "Rome", + Street = null + } + }, + new EfCorePerson { + FirstName = "No", + LastName = "Address", + HomeAddress = null! + } + ); + + db.SaveChanges(); + + var mapify = new Mapify([ + new EfCorePersonStreetNumberProfile(), + new EfCorePersonStreetNullableNumberProfile() + ]); + + var nonNullableProjection = db.People + .OrderBy(x => x.Id) + .ProjectTo(mapify) + .Select(x => x.StreetNumber) + .OrderBy(x => x) + .ToArray(); + + var nullableProjection = db.People + .OrderBy(x => x.Id) + .ProjectTo(mapify) + .Select(x => x.StreetNumber) + .OrderBy(x => x) + .ToArray(); + + Assert.Equal(new[] { 0, 0, 77 }, nonNullableProjection); + Assert.Equal(new int?[] { null, null, 77 }, nullableProjection); + } + [Fact] public void InstanceMapify_NestedNamedProjectToMarker_ShouldWorkInEfCoreProjection() { var options = new DbContextOptionsBuilder() diff --git a/Mapify.NET.Tests/MapifyInstanceTests.cs b/Mapify.NET.Tests/MapifyInstanceTests.cs index 0c870a8..5536fe4 100644 --- a/Mapify.NET.Tests/MapifyInstanceTests.cs +++ b/Mapify.NET.Tests/MapifyInstanceTests.cs @@ -434,6 +434,36 @@ private class RecursiveNodeDepthElevenTarget { public RecursiveNodeDepthElevenTarget? Next { get; set; } } + private class NullSafeLeafSource { + public int Number { get; set; } + public int? OptionalNumber { get; set; } + } + + private class NullSafeStreetSource { + public NullSafeLeafSource? Leaf { get; set; } + } + + private class NullSafeAddressSource { + public NullSafeStreetSource? Street { get; set; } + } + + private class NullSafePersonSource { + public NullSafeAddressSource? Address { get; set; } + public NullSafeAddressSource? SecondaryAddress { get; set; } + } + + private class NullSafePersonTarget { + public int Number { get; set; } + } + + private class NullSafePersonNullableTarget { + public int? Number { get; set; } + } + + private class NullSafePersonCoalesceTarget { + public int Number { get; set; } + } + private enum FaultProfileMode { None, WhitespaceName, @@ -1049,6 +1079,24 @@ protected override void Configure() { } } + private class NullSafeNestedMemberAccessProfile : MapifyProfile { + protected override void Configure() { + CreateMap(x => new NullSafePersonTarget { + Number = x.Address!.Street!.Leaf!.Number + }); + + CreateMap(x => new NullSafePersonNullableTarget { + Number = x.Address!.Street!.Leaf!.Number + }); + + CreateMap(x => new NullSafePersonCoalesceTarget { + Number = x.Address!.Street!.Leaf!.OptionalNumber + ?? x.SecondaryAddress!.Street!.Leaf!.OptionalNumber + ?? 0 + }); + } + } + [Fact] public void Map_ToNewObject_ShouldWorkLikeStaticMapper() { var mapify = new Mapify(); @@ -1672,6 +1720,42 @@ public void Map_ShouldLiftNonNullableMap_ForAllNullableVariants() { Assert.Null(r4Null.Number); } + [Fact] + public void Map_ShouldApplyFallback_ForNullNestedMemberChain_WhenDestinationIsNonNullable() { + var mapify = new Mapify(new NullSafeNestedMemberAccessProfile()); + + var mapped = mapify.Map(new NullSafePersonSource { + Address = new NullSafeAddressSource { + Street = null + } + }); + + Assert.Equal(0, mapped.Number); + } + + [Fact] + public void Map_ShouldApplyNull_ForNullNestedMemberChain_WhenDestinationIsNullable() { + var mapify = new Mapify(new NullSafeNestedMemberAccessProfile()); + + var mapped = mapify.Map(new NullSafePersonSource { + Address = null + }); + + Assert.Null(mapped.Number); + } + + [Fact] + public void Map_ShouldGuardNestedFallbackBranch_InCoalesceExpression() { + var mapify = new Mapify(new NullSafeNestedMemberAccessProfile()); + + var mapped = mapify.Map(new NullSafePersonSource { + Address = null, + SecondaryAddress = null + }); + + Assert.Equal(0, mapped.Number); + } + [Fact] public void Map_ShouldBuildTransitiveDependencies_WhenProfilesAreUnordered() { // Registration order is reverse dependency order: root -> middle -> leaf. diff --git a/Mapify/Mapify.MappingInternals.cs b/Mapify/Mapify.MappingInternals.cs index 08a41d1..d6b2f20 100644 --- a/Mapify/Mapify.MappingInternals.cs +++ b/Mapify/Mapify.MappingInternals.cs @@ -204,6 +204,7 @@ private static PropertyInfo GetMapBuilderDestinationProperty !type.IsValueType || Nullable.GetUnderlyingType(type) != null; + private static Expression? BuildNestedMemberAccessGuard(Expression expression) { + var collector = new NestedMemberAccessNullGuardCollector(); + collector.Visit(expression); + return collector.BuildGuard(); + } + + private static Expression ApplyNestedNullSafety(Expression expression, PropertyInfo? destinationProperty) { + var fallback = CreateAssignmentFallbackExpression(expression.Type, destinationProperty); + return ApplyNestedNullSafetyCore(expression, fallback); + } + + private static Expression ApplyNestedNullSafetyCore(Expression expression, Expression fallback) { + if (expression is ConditionalExpression conditionalExpression) { + var guardedTest = ApplyNestedNullSafetyToBooleanExpression(conditionalExpression.Test); + var guardedIfTrue = ApplyNestedNullSafetyCore( + conditionalExpression.IfTrue, + AdaptFallbackToType(fallback, conditionalExpression.IfTrue.Type) + ); + var guardedIfFalse = ApplyNestedNullSafetyCore( + conditionalExpression.IfFalse, + AdaptFallbackToType(fallback, conditionalExpression.IfFalse.Type) + ); + + return Expression.Condition(guardedTest, guardedIfTrue, guardedIfFalse); + } + + var guard = BuildNestedMemberAccessGuard(expression); + if (guard == null) { + return expression; + } + + return Expression.Condition(guard, expression, AdaptFallbackToType(fallback, expression.Type)); + } + + private static Expression ApplyNestedNullSafetyToBooleanExpression(Expression testExpression) { + if (testExpression is ConditionalExpression conditionalExpression) { + var guardedTest = ApplyNestedNullSafetyToBooleanExpression(conditionalExpression.Test); + var guardedIfTrue = ApplyNestedNullSafetyToBooleanExpression(conditionalExpression.IfTrue); + var guardedIfFalse = ApplyNestedNullSafetyToBooleanExpression(conditionalExpression.IfFalse); + return Expression.Condition(guardedTest, guardedIfTrue, guardedIfFalse); + } + + var guard = BuildNestedMemberAccessGuard(testExpression); + if (guard == null) { + return testExpression; + } + + return Expression.Condition(guard, testExpression, Expression.Constant(false)); + } + + private static Expression CreateAssignmentFallbackExpression(Type targetType, PropertyInfo? destinationProperty) { + var fallback = destinationProperty != null + ? CreatePropertyDefaultValueExpression(destinationProperty) + : CreateDefaultValueExpression(targetType); + + return AdaptFallbackToType(fallback, targetType); + } + + private static Expression AdaptFallbackToType(Expression fallback, Type targetType) { + if (fallback.Type == targetType) { + return fallback; + } + + if (TryAdaptMappedResult(fallback, targetType, out var adapted)) { + return adapted; + } + + return CreateDefaultValueExpression(targetType); + } + + private sealed class NestedMemberAccessNullGuardCollector : ExpressionVisitor { + private readonly List _checks = []; + + public Expression? BuildGuard() { + if (_checks.Count == 0) { + return null; + } + + Expression combined = _checks[0]; + for (var i = 1; i < _checks.Count; i++) { + combined = Expression.AndAlso(combined, _checks[i]); + } + + return combined; + } + + protected override Expression VisitMember(MemberExpression node) { + if (node.Expression != null) { + Visit(node.Expression); + + if (RequiresNullCheck(node.Expression)) { + _checks.Add(CreateHasValueCheck(node.Expression)); + } + } + + return node; + } + + protected override Expression VisitMethodCall(MethodCallExpression node) { + if (node.Object != null) { + Visit(node.Object); + + if (RequiresNullCheck(node.Object)) { + _checks.Add(CreateHasValueCheck(node.Object)); + } + } + + foreach (var argument in node.Arguments) { + Visit(argument); + } + + return node; + } + + private static bool RequiresNullCheck(Expression expression) + => CanBeNull(expression.Type) && expression is not ParameterExpression; + } + private static bool IsCollectionLikeType(Type type) => type != typeof(string) && typeof(System.Collections.IEnumerable).IsAssignableFrom(type) diff --git a/README.md b/README.md index d248c79..221bbf9 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,22 @@ Binding precedence is: DSL value expressions run through the same marker pipeline, so markers like `UseMap`, `Ignore`, and `Parameter` also work in `.Map(...)`. +Nested member access is null-safe for both in-memory mapping and `IQueryable` projection. +When an intermediate source member is `null`, Mapify emits a conditional expression: + +- destination member is nullable: result is `null` +- destination member is non-nullable: result is the configured fallback/default value + +Example: + +```csharp +CreateMap(p => new PersonDto { + // If Address, Address.Street, or Address.Street.Name is null, + // StreetName gets fallback based on destination nullability. + StreetName = p.Address.Street.Name +}); +``` + Example with markers in DSL mappings: ```csharp