From e6b127583d250969d4fff5e162b7c19cb855a59a Mon Sep 17 00:00:00 2001 From: Blaise Taylor Date: Tue, 24 Feb 2026 09:35:47 -0500 Subject: [PATCH 1/3] Adding coverlet threshold to builds. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- README.md | 69 +++++++++++++- .../XpressionMapperVisitor.cs | 12 ++- ...ipleDestinationTypesInTheSameExpression.cs | 94 +++++++++++++++++++ .../XpressionMapperTests.cs | 40 ++++++++ 6 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5411daa..c30c05d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: run: dotnet nuget add source https://www.myget.org/F/automapperdev/api/v3/index.json -n automappermyget - name: Test - run: dotnet test --configuration Release --verbosity normal + run: dotnet test --configuration Release --verbosity normal /p:CollectCoverage=true /p:Threshold=94 /p:ThresholdType=line /p:ThresholdStat=Average /p:CoverletOutputFormat=opencover /p:CoverletOutput=./TestResults/ /p:ExcludeByAttribute="GeneratedCodeAttribute" - name: Pack and push env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 918d2ea..e1d27ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -17,7 +17,7 @@ jobs: fetch-depth: 0 - name: Test - run: dotnet test --configuration Release --verbosity normal + run: dotnet test --configuration Release --verbosity normal /p:CollectCoverage=true /p:Threshold=94 /p:ThresholdType=line /p:ThresholdStat=Average /p:CoverletOutputFormat=opencover /p:CoverletOutput=./TestResults/ /p:ExcludeByAttribute="GeneratedCodeAttribute" - name: Pack and push env: diff --git a/README.md b/README.md index 4f7f575..94d1814 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ The methods below map the DTO query expresions to the equivalent data query expr return mapper.Map(mappedQueryFunc(query)); } - //This version compiles the queryable expression. + //This example compiles the queryable expression. internal static IQueryable GetQuery1(this IQueryable query, IMapper mapper, Expression> filter = null, @@ -87,7 +87,7 @@ The methods below map the DTO query expresions to the equivalent data query expr Expression>[] GetExpansions() => expansions?.ToArray() ?? []; } - //This version updates IQueryable.Expression with the mapped queryable expression parameter. + //This example updates IQueryable.Expression with the mapped queryable expression argument. internal static IQueryable GetQuery2(this IQueryable query, IMapper mapper, Expression> filter = null, @@ -128,3 +128,68 @@ The methods below map the DTO query expresions to the equivalent data query expr } } ``` + +## Know Issues +Mapping a single type in the source expression to multiple types in the destination expression is not supported e.g. +```c# +``` + [Fact] + public void Can_map_if_source_type_targets_multiple_destination_types_in_the_same_expression() + { + var mapper = ConfigurationHelper.GetMapperConfiguration(cfg => + { + cfg.CreateMap().ReverseMap(); + cfg.CreateMap().ReverseMap(); + + // Same source type can map to different target types. This seems unsupported currently. + cfg.CreateMap().ReverseMap(); + cfg.CreateMap().ReverseMap(); + + }).CreateMapper(); + + Expression> sourcesWithListItemsExpr = src => src.Id != 0 && src.ItemList.Any() && src.Child.ItemList.Any(); // Sources with non-empty ItemList + Expression> target1sWithListItemsExpr = mapper.MapExpression>>(sourcesWithListItemsExpr); + } + + private class SourceChildType + { + public int Id { get; set; } + public IEnumerable ItemList { get; set; } // Uses same type (SourceListItemType) for its itemlist as SourceType + } + + private class SourceType + { + public int Id { get; set; } + public SourceChildType Child { set; get; } + public IEnumerable ItemList { get; set; } + } + + private class SourceListItemType + { + public int Id { get; set; } + } + + private class TargetChildType + { + public virtual int Id { get; set; } + public virtual ICollection ItemList { get; set; } = []; + } + + private class TargetChildListItemType + { + public virtual int Id { get; set; } + } + + private class TargetType + { + public virtual int Id { get; set; } + + public virtual TargetChildType Child { get; set; } + + public virtual ICollection ItemList { get; set; } = []; + } + + private class TargetListItemType + { + public virtual int Id { get; set; } + } \ No newline at end of file diff --git a/src/AutoMapper.Extensions.ExpressionMapping/XpressionMapperVisitor.cs b/src/AutoMapper.Extensions.ExpressionMapping/XpressionMapperVisitor.cs index 672f60f..153df02 100644 --- a/src/AutoMapper.Extensions.ExpressionMapping/XpressionMapperVisitor.cs +++ b/src/AutoMapper.Extensions.ExpressionMapping/XpressionMapperVisitor.cs @@ -183,7 +183,8 @@ protected override Expression VisitLambda(Expression node) protected override Expression VisitNew(NewExpression node) { - if (this.TypeMappings.TryGetValue(node.Type, out Type newType)) + Type newType = this.TypeMappingsManager.ReplaceType(node.Type); + if (newType != node.Type && !IsAnonymousType(node.Type)) { return Expression.New(newType); } @@ -217,7 +218,8 @@ private static bool IsAnonymousType(Type type) protected override Expression VisitMemberInit(MemberInitExpression node) { - if (this.TypeMappings.TryGetValue(node.Type, out Type newType)) + Type newType = this.TypeMappingsManager.ReplaceType(node.Type); + if (newType != node.Type && !IsAnonymousType(node.Type)) { var typeMap = ConfigurationProvider.CheckIfTypeMapExists(sourceType: newType, destinationType: node.Type); //The destination becomes the source because to map a source expression to a destination expression, @@ -474,7 +476,8 @@ Expression DoVisitConditional(Expression test, Expression ifTrue, Expression ifF protected override Expression VisitTypeBinary(TypeBinaryExpression node) { - if (this.TypeMappings.TryGetValue(node.TypeOperand, out Type mappedType)) + Type mappedType = this.TypeMappingsManager.ReplaceType(node.TypeOperand); + if (mappedType != node.TypeOperand) return MapTypeBinary(this.Visit(node.Expression)); return base.VisitTypeBinary(node); @@ -498,7 +501,8 @@ protected override Expression VisitUnary(UnaryExpression node) Expression DoVisitUnary(Expression updated) { - if (this.TypeMappings.TryGetValue(node.Type, out Type mappedType)) + Type mappedType = this.TypeMappingsManager.ReplaceType(node.Type); + if (mappedType != node.Type) return Expression.MakeUnary ( node.NodeType, diff --git a/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs b/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs new file mode 100644 index 0000000..c9bd2c9 --- /dev/null +++ b/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs @@ -0,0 +1,94 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Xunit; + +namespace AutoMapper.Extensions.ExpressionMapping.UnitTests +{ + public class CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression + { +#pragma warning disable xUnit1004 // Test methods should not be skipped + [Fact(Skip = "This test is currently skipped due to unsupported scenario.")] +#pragma warning restore xUnit1004 // Test methods should not be skipped + public void Can_map_if_source_type_targets_multiple_destination_types_in_the_same_expression() + { + var mapper = ConfigurationHelper.GetMapperConfiguration(cfg => + { + cfg.CreateMap().ReverseMap(); + cfg.CreateMap().ReverseMap(); + + // Same source type can map to different target types. This seems unsupported currently. + cfg.CreateMap().ReverseMap(); + cfg.CreateMap().ReverseMap(); + + }).CreateMapper(); + + Expression> sourcesWithListItemsExpr = src => src.Id != 0 && src.ItemList.Any() && src.Child.ItemList.Any(); // Sources with non-empty ItemList + Expression> target1sWithListItemsExpr = mapper.MapExpression>>(sourcesWithListItemsExpr); + } + +#pragma warning disable xUnit1004 // Test methods should not be skipped + [Fact(Skip = "This test is currently skipped due to unsupported scenario.")] +#pragma warning restore xUnit1004 // Test methods should not be skipped + public void Can_map_if_source_type_targets_multiple_destination_types_in_the_same_expression_including_nested_parameters() + { + var mapper = ConfigurationHelper.GetMapperConfiguration(cfg => + { + cfg.CreateMap().ReverseMap(); + cfg.CreateMap().ReverseMap(); + + // Same source type can map to different target types. This seems unsupported currently. + cfg.CreateMap().ReverseMap(); + cfg.CreateMap().ReverseMap(); + + }).CreateMapper(); + + Expression> sourcesWithListItemsExpr = src => src.Id != 0 && src.ItemList.FirstOrDefault(i => i.Id == 1) == null && src.Child.ItemList.FirstOrDefault(i => i.Id == 1) == null; // Sources with non-empty ItemList + Expression> target1sWithListItemsExpr = mapper.MapExpression>>(sourcesWithListItemsExpr); + } + + private class SourceChildType + { + public int Id { get; set; } + public IEnumerable ItemList { get; set; } // Uses same type (SourceListItemType) for its itemlist as SourceType + } + + private class SourceType + { + public int Id { get; set; } + public SourceChildType Child { set; get; } + public IEnumerable ItemList { get; set; } + } + + private class SourceListItemType + { + public int Id { get; set; } + } + + private class TargetChildType + { + public virtual int Id { get; set; } + public virtual ICollection ItemList { get; set; } = []; + } + + private class TargetChildListItemType + { + public virtual int Id { get; set; } + } + + private class TargetType + { + public virtual int Id { get; set; } + + public virtual TargetChildType Child { get; set; } + + public virtual ICollection ItemList { get; set; } = []; + } + + private class TargetListItemType + { + public virtual int Id { get; set; } + } + } +} diff --git a/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/XpressionMapperTests.cs b/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/XpressionMapperTests.cs index ec62519..5ba21fa 100644 --- a/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/XpressionMapperTests.cs +++ b/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/XpressionMapperTests.cs @@ -19,6 +19,36 @@ public XpressionMapperTests() #region Tests + [Fact] + public void Map_expression_list() + { + //Arrange + ICollection>> selections = [s => s.AccountModel.Bal, s => s.AccountName]; + + //Act + List>> selectionsMapped = [.. mapper.MapExpressionList>>(selections)]; + List accounts = [.. Users.Select(selectionsMapped[0])]; + List branches = [.. Users.Select(selectionsMapped[1])]; + + //Assert + Assert.True(accounts.Count == 2 && branches.Count == 2); + } + + [Fact] + public void Map_expression_list_using_two_generic_arguments_override() + { + //Arrange + ICollection>> selections = [s => s.AccountModel.Bal, s => s.AccountName]; + + //Act + List>> selectionsMapped = [.. mapper.MapExpressionList>, Expression >>(selections)]; + List accounts = [.. Users.Select(selectionsMapped[0])]; + List branches = [.. Users.Select(selectionsMapped[1])]; + + //Assert + Assert.True(accounts.Count == 2 && branches.Count == 2); + } + [Fact] public void Map_object_type_change() { @@ -895,6 +925,16 @@ public void Can_map_expression_with_condittional_logic_while_deflattening() Assert.NotNull(mappedExpression); } + [Fact] + public void Returns_null_when_soure_is_null() + { + Expression> expr = null; + + var mappedExpression = mapper.MapExpression>>(expr); + + Assert.Null(mappedExpression); + } + [Fact] public void Can_map_expression_with_multiple_destination_parameters_of_the_same_type() { From 32e5486a07d8f9e7505744d56918eb826c1c2158 Mon Sep 17 00:00:00 2001 From: Blaise Taylor Date: Tue, 24 Feb 2026 09:37:38 -0500 Subject: [PATCH 2/3] minor --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 94d1814..60b9586 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,7 @@ The methods below map the DTO query expresions to the equivalent data query expr } ``` -## Know Issues +## Known Issues Mapping a single type in the source expression to multiple types in the destination expression is not supported e.g. ```c# ``` From 4dc689ffc0cd43b3bd3dcefca3b4ce3935cf2b4b Mon Sep 17 00:00:00 2001 From: Blaise Taylor Date: Tue, 24 Feb 2026 09:45:14 -0500 Subject: [PATCH 3/3] CodeQL fixes. --- ...sMultipleDestinationTypesInTheSameExpression.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs b/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs index c9bd2c9..c614bac 100644 --- a/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs +++ b/tests/AutoMapper.Extensions.ExpressionMapping.UnitTests/CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpression.cs @@ -13,6 +13,7 @@ public class CanMapIfASourceTypeTargetsMultipleDestinationTypesInTheSameExpressi #pragma warning restore xUnit1004 // Test methods should not be skipped public void Can_map_if_source_type_targets_multiple_destination_types_in_the_same_expression() { + // Arrange var mapper = ConfigurationHelper.GetMapperConfiguration(cfg => { cfg.CreateMap().ReverseMap(); @@ -23,9 +24,13 @@ public void Can_map_if_source_type_targets_multiple_destination_types_in_the_sam cfg.CreateMap().ReverseMap(); }).CreateMapper(); - Expression> sourcesWithListItemsExpr = src => src.Id != 0 && src.ItemList.Any() && src.Child.ItemList.Any(); // Sources with non-empty ItemList + + // Act Expression> target1sWithListItemsExpr = mapper.MapExpression>>(sourcesWithListItemsExpr); + + // Assert + Assert.NotNull(target1sWithListItemsExpr); } #pragma warning disable xUnit1004 // Test methods should not be skipped @@ -33,6 +38,7 @@ public void Can_map_if_source_type_targets_multiple_destination_types_in_the_sam #pragma warning restore xUnit1004 // Test methods should not be skipped public void Can_map_if_source_type_targets_multiple_destination_types_in_the_same_expression_including_nested_parameters() { + // Arrange var mapper = ConfigurationHelper.GetMapperConfiguration(cfg => { cfg.CreateMap().ReverseMap(); @@ -43,9 +49,13 @@ public void Can_map_if_source_type_targets_multiple_destination_types_in_the_sam cfg.CreateMap().ReverseMap(); }).CreateMapper(); - Expression> sourcesWithListItemsExpr = src => src.Id != 0 && src.ItemList.FirstOrDefault(i => i.Id == 1) == null && src.Child.ItemList.FirstOrDefault(i => i.Id == 1) == null; // Sources with non-empty ItemList + + // Act Expression> target1sWithListItemsExpr = mapper.MapExpression>>(sourcesWithListItemsExpr); + + //Assert + Assert.NotNull(target1sWithListItemsExpr); } private class SourceChildType