AutoMapper MapperConfiguration(config => c.AddMaps(assembly)) overload not registering closed generic maps

80 Views Asked by At

I'm working on a .Net 8.0 project based on Jason Taylor's Clean Architecture template. In this project, AutoMapper is used throughout to map between domain entities and data-transfer objects (DTOs). In the application's unit test project, there are tests defined to validate the defined mappings. I recently introduced some DTOs that make use of generics to set the datatype for the Id property on a lookup object. After introducing the generics (and their associated maps), my AutoMapper tests began to fail, but only for the generics and only where the AddMaps overload is used. If the maps are explicitly added using the MapperConfiguration(config => c.CreateMap(src,dest) overload, the maps work as expected.

Believing this to be an AutoMapper bug, I opened an issue in the AutoMapper repo, but I was directed here instead.

I created a .NET Fiddle demonstrating the issue in browser, and the full reproduction code is below. Is there a configuration oversight, feature limitation, or is this an AutoMapper bug?

public class MyClass
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
}

public record MyClassDto 
{
    public int Id { get; init; }
    public string Name { get; init; } = null!;
    
    private class Mapping : Profile
    {
        public Mapping() => CreateMap<MyClass, MyClassDto>();
    }
}

public record MyGenericClassDto<T>
{
    public T Id { get; init; } = default!;
    public string Name { get; init; } = null!;
    
    private class Mapping : Profile
    {
        public Mapping() => CreateMap<MyClass, MyGenericClassDto<int>>();
    }
}

public class MappingTests
{
    [Test]
    // Will succeed
    [TestCase(typeof(MyClass), typeof(MyClassDto))]
    // Will fail
    [TestCase(typeof(MyClass), typeof(MyGenericClassDto<int>))]
    public void ShouldSupportMappingFromSourceToDestination_AddMaps(
        Type source, 
        Type destination)
    {
        var instance = GetInstanceOf(source);
        var configuration = new MapperConfiguration(config => 
            config.AddMaps(Assembly.GetAssembly(typeof(MyClass))));
        var mapper = configuration.CreateMapper();

        mapper.Map(instance, source, destination);
    }
    
    [Test]
    // Will succeed
    [TestCase(typeof(MyClass), typeof(MyClassDto))]
    // Will succeed
    [TestCase(typeof(MyClass), typeof(MyGenericClassDto<int>))]
    public void ShouldSupportMappingFromSourceToDestination_CreateMap(
        Type source, 
        Type destination)
    {
        var instance = GetInstanceOf(source);
        var config = new MapperConfiguration(cfg => 
            cfg.CreateMap(source, destination));
        var mapper = new Mapper(config);

        mapper.Map(instance, source, destination);
    }

    private static object GetInstanceOf(Type type) =>
        type.GetConstructor(Type.EmptyTypes) != null 
            ? Activator.CreateInstance(type)! :
            // Type without parameterless constructor
            RuntimeHelpers.GetUninitializedObject(type);
}
1

There are 1 best solutions below

0
JD Davis On

Approach 1: Patch AutoMapper

After messing around with this a bit more, I started investigating the AutoMapper source code in an effort to track down this behavior discrepancy, and I have identified the affected code. I've copied the method below, removing the irrelevant bits.

private void AddMapsCore(IEnumerable<Assembly> assembliesToScan)
{
    var autoMapAttributeProfile = new Profile(nameof(AutoMapAttribute));
    foreach (var type in assembliesToScan.Where(a => !a.IsDynamic && a != typeof(Profile).Assembly).SelectMany(a => a.GetTypes()))
    {
        if (typeof(Profile).IsAssignableFrom(type) && !type.IsAbstract && !type.ContainsGenericParameters)
        {
            AddProfile(type);
        }

        foreach (var autoMapAttribute in type.GetCustomAttributes<AutoMapAttribute>())
        {
            // Scans assemblies with AutoMap attribute. Irrelevant to the question.
        }
    }

    AddProfile(autoMapAttributeProfile);
}

You'll notice just inside the foreach loop, AutoMapper explicitly ignores any Profile classes that are generic (or that are nested within a generic). If you follow that AddProfile(type) call, you'll see why:

public void AddProfile(Type profileType) => AddProfile((Profile)Activator.CreateInstance(profileType));

The method simply instantiates a new instance of the class implementing Profile. For generic classes, it's necessary to provide the type arguments to the class being instantiated. With that in mind, we can create a few new methods, and we can update the AddMapsCore method to leverage them for generic classes.

public void AddGenericProfile(Type profileType) => AddProfile((Profile)GetInstanceOfGeneric(profileType));

private static object GetInstanceOfGeneric(Type genericType)
{
    var typeArgs = GetGenericArguments(genericType);
    var constructedType = genericType.MakeGenericType(typeArgs);

    return Activator.CreateInstance(constructedType);
}

private static Type[] GetGenericArguments(
    Type genericType) =>
    genericType.GetGenericArguments()
        .Select(_ => typeof(object))
        .ToArray();

private void AddMapsCore(IEnumerable<Assembly> assembliesToScan)
{
    var autoMapAttributeProfile = new Profile(nameof(AutoMapAttribute));
    foreach (var type in assembliesToScan.Where(a => !a.IsDynamic && a != typeof(Profile).Assembly).SelectMany(a => a.GetTypes()))
    {
        if (typeof(Profile).IsAssignableFrom(type) && !type.IsAbstract)
        {
            if (type.ContainsGenericParameters)
            {
                AddGenericProfile(type);
            }
            else
            {
                AddProfile(type);    
            }
        }

        foreach (var autoMapAttribute in type.GetCustomAttributes<AutoMapAttribute>())
        {
            // Scans assemblies with AutoMap attribute. Irrelevant to the question.
        }
    }

    AddProfile(autoMapAttributeProfile);
}

With these updates, our tests as written in the OP will pass. To validate this, I forked and updated AutoMapper with the changes and introduced the new tests to cover these scenarios. All existing tests and the new ones passed. The Pull Request for the change is here, but unfortunately it was closed by the maintainers without much/any feedback.

Approach 2: Move Nested Maps Outside of Generics

If efforts to resolve the AutoMapper usage inconsistency through a PR are unsuccessful, it is possible to work around this issue by moving the nested Profile to its own class outside of the generic class it was originally defined in. The use case in the OP can be solved by updating the code to the following:

public record MyGenericClassDto<T>
{
    public T Id { get; init; } = default!;
    public string Name { get; init; } = null!;
}

internal class GenericMapping : Profile
{
    public GenericMapping() => CreateMap<MyClass, MyGenericClassDto<int>>();
}

This usage is not fully synonymous with that in the OP as it leaks details of the mapping class outside of its original containing class, whereas the non-generic version can be fully encapsulated. More importantly, it creates an inconsistency when it comes to implementation details. Source generators, analyzers, and other tools designed to help abstract away some of these details will need to be aware of this discrepancy.