Search code examples
c#automapper

Why can't AutoMapper convert a string to a bool when the destination class has a parameterized constructor?


I have a class Destination with a constructor that takes parameters, and I'm trying to map a Source object to Destination using AutoMapper. The mapping for Id works fine, but the mapping for InStore doesn't seem to work when I use a custom mapping. Here is my code:

using AutoMapper;

var config = new MapperConfiguration(cfg =>
{
    cfg.CreateMap<Source, Destination>()
       .ForMember(dest => dest.Id, opt => opt.MapFrom(src => int.Parse(src.Id)))
       .ForMember(dest => dest.InStore, opt => opt.MapFrom(src => src.InStore == "1"));
});

var mapper = config.CreateMapper();

var source = new Source { Id = "123", InStore = "1" };
var destination = mapper.Map<Destination>(source);

Console.WriteLine($"Id: {destination.Id}, InStore: {destination.InStore}");

public class Source
{
    public string Id { get; set; }
    public string InStore { get; set; }
}

public class Destination
{
    public int Id { get; private set; }
    public bool InStore { get; private set; }

    public Destination(int id, bool inStore)
    {
        Id = id;
        InStore = inStore;
    }
}

Throw exception:

AutoMapper.AutoMapperMappingException: 'Error mapping types.'
FormatException: String '1' was not recognized as a valid Boolean.

When I remove the parameterized constructor from Destination, the mapping works correctly. Also, when var source = new Source { Id = "123", InStore = "true" }; is used, the mapping also works. These observations confuse me as to why the mapping fails with a constructor present and when InStore is set to "1".

The expression tree is as follows:

(Source src, Destination dest, ResolutionContext ctxt) => {
    Destination typeMapDestination;
    return src == default(Source) ? default(Destination) : (
        typeMapDestination = dest ?? new Destination((
            string resolvedValue,
            resolvedValue = false || src == null ? default(string) : src.Id,
            Convert.ToInt32(resolvedValue)
        ), (
            string resolvedValue,
            resolvedValue = false || src == null ? default(string) : src.InStore,
            Convert.ToBoolean(resolvedValue)
        )),
        if (dest != null) {
            try {
                (
                    string resolvedValue,
                    int propertyValue,
                    resolvedValue = false || src == null ? default(string) : src.Id,
                    propertyValue = Convert.ToInt32(resolvedValue),
                    typeMapDestination.Id = propertyValue
                )
            } catch (Exception ex) {
                throw new AutoMapperMappingException("Error mapping types.", ex, #TypePair, #TypeMap, #PropertyMap);
            }
        },
        if (dest != null) {
            try {
                (
                    string resolvedValue,
                    bool propertyValue,
                    resolvedValue = false || src == null ? default(string) : src.InStore,
                    propertyValue = Convert.ToBoolean(resolvedValue),
                    typeMapDestination.InStore = propertyValue
                )
            } catch (Exception ex) {
                throw new AutoMapperMappingException("Error mapping types.", ex, #TypePair, #TypeMap, #PropertyMap);
            }
        },
        try {
            (
                string resolvedValue,
                resolvedValue = try {
                    false || src == null ? default(string) : src.Id;
                } catch (NullReferenceException) {
                    default(string);
                } catch (ArgumentNullException) {
                    default(string);
                },
                typeMapDestination.Name = resolvedValue
            )
        } catch (Exception ex) {
            throw new AutoMapperMappingException("Error mapping types.", ex, #TypePair, #TypeMap, #PropertyMap);
        },
        typeMapDestination
    );
}

It seems like ForMember isn't working as expected. How should I modify it to achieve the desired results?


Solution

  • (as at AutoMapper 13.0.1) Seems that constructor parameter matching simply does NOT use custom ForMember mappings.

    Further simplifying the example:

    var mapper = new MapperConfiguration(cfg =>
      cfg.CreateMap<Source, Destination>()
        .ForMember(dest => dest.Id, opt => opt.MapFrom(
          src => int.Parse(src.Id) * 100 // Custom expression
        ))
    ).CreateMapper();
    
    var dest = mapper.Map<Destination>(new Source("1"));
    Console.WriteLine($"{dest.Id}"); // =1. Should be 100.
    
    public record Source(string Id);
    
    public class Destination {
      public int Id;
      public Destination(int id) => Id = id;
    }
    

    The exec plan reflects that the id param is mapped using Convert.ToInt32() instead of the custom ForMember expression.

    To get around it you have to manually specify a ForCtorParam expression as well in the mapping, or completely disable constructor mapping.

    .ForCtorParam("id",  opt => opt.MapFrom(src => int.Parse(src.Id) * 100))
    // or
    .DisableConstructorMapping()
    

    It's not ideal, and I don't know if this is intended behaviour or a bug.