Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ public partial class MapifyEfCoreProjectionTests {
private sealed class EfCoreMapifyContext(DbContextOptions<EfCoreMapifyContext> options) : DbContext(options) {
public DbSet<EfCorePerson> People => Set<EfCorePerson>();
public DbSet<EfCoreAddress> Addresses => Set<EfCoreAddress>();
public DbSet<EfCoreStreet> Streets => Set<EfCoreStreet>();
public DbSet<EfCorePhone> Phones => Set<EfCorePhone>();
public DbSet<EfCoreRecursiveNode> RecursiveNodes => Set<EfCoreRecursiveNode>();
public DbSet<EfCoreProjectionIgnoreEntity> ProjectionIgnoreEntities => Set<EfCoreProjectionIgnoreEntity>();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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; }
}
}
16 changes: 16 additions & 0 deletions Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTestProfiles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ protected override void Configure() {
}
}

private sealed class EfCorePersonStreetNumberProfile : MapifyProfile {
protected override void Configure() {
CreateMap<EfCorePerson, EfCorePersonStreetNumberDto>(x => new EfCorePersonStreetNumberDto {
StreetNumber = x.HomeAddress.Street!.Number
});
}
}

private sealed class EfCorePersonStreetNullableNumberProfile : MapifyProfile {
protected override void Configure() {
CreateMap<EfCorePerson, EfCorePersonStreetNullableNumberDto>(x => new EfCorePersonStreetNullableNumberDto {
StreetNumber = x.HomeAddress.Street!.Number
});
}
}

private sealed class EfCoreRecursiveNodeDefaultDepthProfile : MapifyProfile {
protected override void Configure() {
CreateMap<EfCoreRecursiveNode, EfCoreRecursiveNodeDto>(x => new EfCoreRecursiveNodeDto {
Expand Down
59 changes: 59 additions & 0 deletions Mapify.NET.Tests.EFCore/MapifyEfCoreProjectionTests.ProjectTo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EfCoreMapifyContext>()
.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<EfCorePersonStreetNumberDto>(mapify)
.Select(x => x.StreetNumber)
.OrderBy(x => x)
.ToArray();

var nullableProjection = db.People
.OrderBy(x => x.Id)
.ProjectTo<EfCorePersonStreetNullableNumberDto>(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<EfCoreMapifyContext>()
Expand Down
84 changes: 84 additions & 0 deletions Mapify.NET.Tests/MapifyInstanceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1049,6 +1079,24 @@ protected override void Configure() {
}
}

private class NullSafeNestedMemberAccessProfile : MapifyProfile {
protected override void Configure() {
CreateMap<NullSafePersonSource, NullSafePersonTarget>(x => new NullSafePersonTarget {
Number = x.Address!.Street!.Leaf!.Number
});

CreateMap<NullSafePersonSource, NullSafePersonNullableTarget>(x => new NullSafePersonNullableTarget {
Number = x.Address!.Street!.Leaf!.Number
});

CreateMap<NullSafePersonSource, NullSafePersonCoalesceTarget>(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();
Expand Down Expand Up @@ -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<NullSafePersonSource, NullSafePersonTarget>(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<NullSafePersonSource, NullSafePersonNullableTarget>(new NullSafePersonSource {
Address = null
});

Assert.Null(mapped.Number);
}

[Fact]
public void Map_ShouldGuardNestedFallbackBranch_InCoalesceExpression() {
var mapify = new Mapify(new NullSafeNestedMemberAccessProfile());

var mapped = mapify.Map<NullSafePersonSource, NullSafePersonCoalesceTarget>(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.
Expand Down
123 changes: 122 additions & 1 deletion Mapify/Mapify.MappingInternals.cs
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ private static PropertyInfo GetMapBuilderDestinationProperty<TSource, TDestinati
isIgnored = false;

var expr = destinationExpression;
var destinationProperty = destinationMember as PropertyInfo;

if (TryResolveIgnoreMarker(destinationMember, expr, destinationType, out var ignoredBinding)) {
isIgnored = true;
Expand All @@ -227,7 +228,7 @@ private static PropertyInfo GetMapBuilderDestinationProperty<TSource, TDestinati
expr = Expression.Condition(test, value, binaryExpr.Right);
}

if (tryMapExpressionToDestinationProperty && destinationMember is PropertyInfo destinationProperty) {
if (tryMapExpressionToDestinationProperty && destinationProperty != null) {
if (!TryAdaptMappedResult(expr, destinationProperty.PropertyType, out var adaptedExpression)) {
if (existingMapResolver == null
|| !TryBuildMappedExpression(expr, expr.Type, destinationProperty.PropertyType, existingMapResolver, null, out adaptedExpression, out var sourceNullCheck)) {
Expand All @@ -250,6 +251,8 @@ private static PropertyInfo GetMapBuilderDestinationProperty<TSource, TDestinati
expr = adaptedExpression;
}

expr = ApplyNestedNullSafety(expr, destinationProperty);

return Expression.Bind(destinationMember, expr);
}

Expand Down Expand Up @@ -1128,6 +1131,124 @@ private static bool TryAdaptMappedResult(Expression mappedBody, Type targetType,
private static bool CanBeNull(Type type)
=> !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<Expression> _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)
Expand Down
Loading
Loading