admin管理员组

文章数量:1292446

I'm experiencing an issue where AutoMapper appears to lose a custom comparer when mapping a HashSet property. I have a DTO that contains a collection of strings and a destination model that expects an IReadOnlySet. I want the resulting HashSet to use StringComparer.OrdinalIgnoreCase so that string comparisons are case insensitive.

However, when I use AutoMapper with a mapping configuration like this…

record ReadOnlySetTestModel(IReadOnlySet<string> Values);
record DtoWithCollection(IReadOnlyCollection<string> Values);


var dto = new DtoWithCollection(["a", "B"]);

var config = new MapperConfiguration(cfg =>
{
    cfg.CreateMap<DtoWithCollection, ReadOnlySetTestModel>()
       .ForMember(dest => dest.Values, opt => opt.MapFrom(src =>
           src.Values.Distinct(StringComparer.OrdinalIgnoreCase)
               .ToHashSet(StringComparer.OrdinalIgnoreCase)
       ));
});

var mapper = config.CreateMapper();
var result = mapper.Map<ReadOnlySetTestModel>(dto);

Assert.Contains("A", result.Values); // Fails: "A" is not found even though "a" exists

the resulting HashSet loses its case-insensitive comparer.

Is there any way to configure AutoMapper to preserve the custom comparer for the HashSet during mapping? I've already tried using MapFrom and ConvertUsing, but it appears that AutoMapper reconstructs the collection internally, which loses the custom comparer. I have multiple nested mappings in production, so I cannot simply use a custom constructor mapping (e.g., ForCtorParam) for this one property.

When I create the HashSet manually using the following code, everything works as expected:

var hashSet = dto.Values
    .Distinct(StringComparer.OrdinalIgnoreCase)
    .ToHashSet(StringComparer.OrdinalIgnoreCase);

var result = new ReadOnlySetTestModel(hashSet);

Assert.Contains("a", result.Values);
Assert.Contains("A", result.Values);

These test pass because the custom comparer is preserved.

Here is link to an easy to download UnitTest repro.

Environment:

  • AutoMapper version: 13.0.1
  • .NET version: 5.0+

I think it is an internal AutoMapper issue. But author closed it that I should take it here.

I'm experiencing an issue where AutoMapper appears to lose a custom comparer when mapping a HashSet property. I have a DTO that contains a collection of strings and a destination model that expects an IReadOnlySet. I want the resulting HashSet to use StringComparer.OrdinalIgnoreCase so that string comparisons are case insensitive.

However, when I use AutoMapper with a mapping configuration like this…

record ReadOnlySetTestModel(IReadOnlySet<string> Values);
record DtoWithCollection(IReadOnlyCollection<string> Values);


var dto = new DtoWithCollection(["a", "B"]);

var config = new MapperConfiguration(cfg =>
{
    cfg.CreateMap<DtoWithCollection, ReadOnlySetTestModel>()
       .ForMember(dest => dest.Values, opt => opt.MapFrom(src =>
           src.Values.Distinct(StringComparer.OrdinalIgnoreCase)
               .ToHashSet(StringComparer.OrdinalIgnoreCase)
       ));
});

var mapper = config.CreateMapper();
var result = mapper.Map<ReadOnlySetTestModel>(dto);

Assert.Contains("A", result.Values); // Fails: "A" is not found even though "a" exists

the resulting HashSet loses its case-insensitive comparer.

Is there any way to configure AutoMapper to preserve the custom comparer for the HashSet during mapping? I've already tried using MapFrom and ConvertUsing, but it appears that AutoMapper reconstructs the collection internally, which loses the custom comparer. I have multiple nested mappings in production, so I cannot simply use a custom constructor mapping (e.g., ForCtorParam) for this one property.

When I create the HashSet manually using the following code, everything works as expected:

var hashSet = dto.Values
    .Distinct(StringComparer.OrdinalIgnoreCase)
    .ToHashSet(StringComparer.OrdinalIgnoreCase);

var result = new ReadOnlySetTestModel(hashSet);

Assert.Contains("a", result.Values);
Assert.Contains("A", result.Values);

These test pass because the custom comparer is preserved.

Here is link to an easy to download UnitTest repro.

Environment:

  • AutoMapper version: 13.0.1
  • .NET version: 5.0+

I think it is an internal AutoMapper issue. But author closed it that I should take it here.

Share Improve this question asked Feb 13 at 8:19 Vojtěch VečeřaVojtěch Večeřa 391 silver badge1 bronze badge 2
  • The reason is because In this scenario the .ForMember is not used by the mapper, like if you completely omit it (try commenting it out). I don't have experience with AutoMapper to tell you why. Check yourself (e.g. here is some issue with it). – Sinatr Commented Feb 13 at 10:10
  • maybe add a link to the github issue to the question – Ivan Petrov Commented Feb 13 at 10:17
Add a comment  | 

2 Answers 2

Reset to default 0

The root cause is not that you are using a specific configured HashSet. It comes from the fact that you're using a record. When having a record you have to use .ConvertUsing() and not .ForMember().

The reason why this doesn't fail or even creates a default hashset is because AutoMapper is collection aware and automatically maps properties with same names.

Here is some code that illustrates the right usage:

public static class Program
{
    public static void Main()
    {
        var configuration = new MapperConfiguration(config =>
        {
            config.CreateMap<Source, DestinationRecord>()
                .ConvertUsing(source => new DestinationRecord(new HashSet<string>(source.Values, StringComparer.OrdinalIgnoreCase)));

            config.CreateMap<Source, DestinationClass>()
                .ForMember(dest => dest.Values, opt => opt.MapFrom(src => new HashSet<string>(src.Values, StringComparer.OrdinalIgnoreCase)));
        });

        var mapper = configuration.CreateMapper();
        var source = new Source(["a", "A"]);
        var destinationRecord = mapper.Map<DestinationRecord>(source);
        var destinationClass = mapper.Map<DestinationClass>(source);

        foreach (var item in destinationRecord.Values)
        {
            Console.WriteLine(item);
        }

        foreach (var item in destinationClass.Values)
        {
            Console.WriteLine(item);
        }
    }
}

public record Source(IReadOnlyCollection<string> Values);
public record DestinationRecord(IReadOnlySet<string> Values);

public class DestinationClass
{
    public required IReadOnlySet<string> Values { get; init; }
}

The only aspect that AutoMapper currently doesn't support and doesn't makes this more clear is the fact that this doesn't throw:

var configuration = new MapperConfiguration(config =>
{
    config.CreateMap<Source, DestinationRecord>()
        .ForMember(dest => dest.Values, config => config.MapFrom(source => source.Values))
});

// This should potentially throw, but currently doesn't.
configuration.AssertConfigurationIsValid();

At the moment ForMember has no effect. What you observe is due to

  1. Both records having the same name Values for their property/constructor parameter
  2. the automatic collection mapping (between IReadOnlyCollection<string> and IReadOnlySet<string>) you get by default which registers a different IEqualityComparer<string> - default one.

You can demo this if you remove the default collection mappers:

var collMapper = cfg.Internal().Mappers
.Where(x => x.ToString().Contains("CollectionMapper"))
.FirstOrDefault();
cfg.Internal().Mappers.Remove(collMapper);

You should get an Exception after that.

You can register a collection mapper on your own to have a different comparer:

cfg.CreateMap<IReadOnlyCollection<string>, IReadOnlySet<string>>()
        .ConvertUsing(h =>
        new HashSet<string>(h,
        StringComparer.OrdinalIgnoreCase));

If you run your code now, it should "work" (even without removing the built-in ones).

However, this logic is supposed to be in ForMember. We are still doing the mapping via the record constructors - relying on Values being the same name. And once we have non-default values there, the ForMember doesn't run.

To force that you need to actually use ConstructUsing:

cfg.CreateMap<DtoWithCollection, ReadOnlySetTestModel>()
    .ConstructUsing(src => new ReadOnlySetTestModel(default))
    .ForMember(dest => dest.Values, opt => opt.MapFrom(
src => src.Values.ToHashSet(StringComparer.OrdinalIgnoreCase)))
;

Unfortunately at this point the default collection mappers are still in use...this time between the result of ToHashSet which is HashSet<string> and IReadOnlySet<string>.

So, the best solution with using ForMember working is:

var config = new MapperConfiguration(cfg => {
    cfg.CreateMap<HashSet<string>, IReadOnlySet<string>>()
         .ConvertUsing(h => h);
    cfg.CreateMap<DtoWithCollection, ReadOnlySetTestModel>()
        .ConstructUsing(src => new ReadOnlySetTestModel(default))
        .ForMember(dest => dest.Values, opt => opt.MapFrom(
    src => src.Values.ToHashSet(StringComparer.OrdinalIgnoreCase)))
    ;
});

This is a lot code to make it work, luckily as the answer by Oliver mentions - we can just fall back on using ConvertUsing which turns off the automatic mappers, uses the constructor as we would have to anyway. The only possible "issue" is that it's not as clean with many properties:

cfg.CreateMap<DtoWithCollection, ReadOnlySetTestModel>()
     .ConvertUsing(source => new ReadOnlySetTestModel(
     source.Values
            .ToHashSet(StringComparer.OrdinalIgnoreCase)));

本文标签: netC AutoMapper loses custom HashSet comparer when mapping to an initonly propertyStack Overflow