How do I teach Automapper to map X to IContainer

2019-08-20 05:05发布

问题:

This came up mapping from models to mvvmcross viewmodels, where you have some container classes you might use, for example:

namespace MvvmCross.Binding
{
  interface INC<T>
  {
    T Value { get; set; }
  }
}
class model
{
  String name { get; set; }
  DateTime when { get; set; }
  othermodel data { get; set; }
}
class viewmodel
{
  INC<String> Name { get; set; }
  INC<String> When { get; set; }
  INC<otherviewmodel> Data { get; set; }
}
  • I need to teach AutoMapper to map from A to INC<B> (and back), without specifiying A or B.

  • Null destination INC<> should be created, non-null should not be re-created.

  • Mapping should continue such that destination.Value = Mapper.Map<A,B>(source).

  • Mapping null --> INC<T> should result in INC<T>.Value = SomeDefaultValue

回答1:

Thanks for Lucians help in the comments.

These mappers will work for Mapper.Map<INC<String>>("test"), but not for Mapper.Map<String, INC<String>>(null, new NC<String>("I shouldnt be here")), since AutoMapper won't send a null source value.

Mapper.Initialize(c =>
{
    c.Mappers.Insert(0, new DestinationObjectContainerMapper());
    c.Mappers.Insert(0, new SourceObjectContainerMapper());
});
Mapper.AssertConfigurationIsValid();

public class DestinationObjectContainerMapper : BlackBoxObjectMapper
{
    bool InterfaceMatch(Type x) => x.IsGenericType && typeof(INC<>).IsAssignableFrom(x.GetGenericTypeDefinition());
    Type CType(Type cdt) => cdt.GetInterfaces().Concat(new[] { cdt }).Where(InterfaceMatch).Select(x => x.GenericTypeArguments[0]).FirstOrDefault();
    public override bool IsMatch(TypePair context) => CType(context.DestinationType) != null;

    public override object Map(object source, Type sourceType, object destination, Type destinationType, ResolutionContext context)
    {
        var dcType = CType(destinationType);

        // Create a container if destination is null
        if (destination == null)
            destination = Activator.CreateInstance(typeof(NC<>).MakeGenericType(dcType));

        // This may also fail because we need the source type
        var setter = typeof(INC<>).MakeGenericType(dcType).GetProperty("Value").GetSetMethod();
        var mappedSource = context.Mapper.Map(source, sourceType, dcType);

        // set the value
        setter.Invoke(destination, new[] { mappedSource });
        return destination;
    }
}
public class SourceObjectContainerMapper : BlackBoxObjectMapper
{
    bool InterfaceMatch(Type x) => x.IsGenericType && typeof(INC<>).IsAssignableFrom(x.GetGenericTypeDefinition());
    Type CType(Type cdt) => cdt.GetInterfaces().Concat(new[] { cdt }).Where(InterfaceMatch).Select(x => x.GenericTypeArguments[0]).FirstOrDefault();
    public override bool IsMatch(TypePair context) => CType(context.SourceType) != null;

    public override object Map(object source, Type sourceType, object destination, Type destinationType, ResolutionContext context)
    {
        // this is only obtainable if destination is not null - so this will not work.
        var scType = CType(sourceType);

        // destination could also be null, this is another anavoidable throw
        object sourceContainedValue = null;
        if (source != null)
        {
            var getter = typeof(INC<>).MakeGenericType(scType).GetProperty("Value").GetGetMethod();
            sourceContainedValue = getter.Invoke(source, new Object[0]);
        }

        // map and return
        return context.Mapper.Map(sourceContainedValue, scType, destinationType);
    }
}

This uses a slightly extended ObjectMapper:

public abstract class BlackBoxObjectMapper : IObjectMapper
{
    private static readonly MethodInfo MapMethod = typeof(BlackBoxObjectMapper).GetTypeInfo().GetDeclaredMethod("Map");

    public abstract bool IsMatch(TypePair context);

    public abstract object Map(object source, Type sourceType, object destination, Type destinationType, ResolutionContext context);

    public Expression MapExpression(IConfigurationProvider configurationProvider, ProfileMap profileMap,
        PropertyMap propertyMap, Expression sourceExpression, Expression destExpression,
        Expression contextExpression) =>
        Expression.Call(
            Expression.Constant(this),
            MapMethod,
            sourceExpression,
            Expression.Constant(sourceExpression.Type),
            destExpression,
            Expression.Constant(destExpression.Type),
            contextExpression);
}


回答2:

Thanks for Lucians help in the comments. This mapper using expressions works, and will receive null values to map as required with a bit of help from this patch and IObjectMapperInfo.CanMapNullSource = true.

The AM team spent a lot of effort avoiding passing expressions that evaluate to null to Mappers, and it definitely simplifies the AutoMapper.Mappers namespace, so this patch is obviously controversial. I thought about less intrusive ways to signal this like a NullSafeExpression, or an anonymous interface moniker like interface ICanHandleNullSourceExpressions {} to try to stem the flow, but I didn't find anything that looks better yet.

public class ContainerDestinationMapper : BaseContainerMapper, IObjectMapperInfo
{

    readonly Func<Type, Expression> createContainer;

    public bool CanMapNullSource => true;

    public ContainerDestinationMapper(Type GenericContainerTypeDefinition, String ContainerPropertyName, Func<Type, Expression> createContainer)
        : base(GenericContainerTypeDefinition, ContainerPropertyName)
    {
        this.createContainer = createContainer;
    }
    public bool IsMatch(TypePair context) => ContainerDef(context.DestinationType) != null;

    public Expression MapExpression(IConfigurationProvider configurationProvider, ProfileMap profileMap,
        PropertyMap propertyMap, Expression sourceExpression, Expression destExpression,
        Expression contextExpression)
    {

        var dparam = DigParameter(destExpression);
        var dVal = Expression.Property(dparam, dparam.Type.GetTypeInfo().GetDeclaredProperty("Value"));
        var cdt = ContainerDef(destExpression.Type);
        var tp = new TypePair(sourceExpression.Type, cdt);
        var ret = Expression.Block
                    (
                        // make destination not null
                        Expression.IfThen
                        (
                            Expression.Equal(dparam, Expression.Constant(null)),
                            Expression.Assign(dparam, createContainer(cdt))
                        ),
                        // Assign to the destination
                        Expression.Assign
                        (
                            dVal,
                            ExpressionBuilder.MapExpression // what you get if you map source to dest.Value
                            (
                                configurationProvider,
                                profileMap,
                                tp,
                                sourceExpression,
                                contextExpression,
                                null,
                                dVal,
                                true
                            )
                        ),
                        destExpression
                    // But we need to return the destination type!
                    // Sadly it will go on to assign destExpression to destExpression.
                    );
        return ret;
    }
    public TypePair GetAssociatedTypes(TypePair initialTypes)
    {
        return new TypePair(initialTypes.SourceType, ContainerDef(initialTypes.DestinationType));
    }
}

public class ContainerSourceMapper : BaseContainerMapper, IObjectMapperInfo
{
    public bool CanMapNullSource => true;

    public ContainerSourceMapper(Type GenericContainerTypeDefinition, String ContainerPropertyName)
        : base(GenericContainerTypeDefinition, ContainerPropertyName) { }

    public bool IsMatch(TypePair context) => ContainerDef(context.SourceType) != null;

    public Expression MapExpression(IConfigurationProvider configurationProvider, ProfileMap profileMap,
        PropertyMap propertyMap, Expression sourceExpression, Expression destExpression,
        Expression contextExpression)
    {
        var dstParam = DigParameter(destExpression);
        return Expression.Block(
            Expression.IfThenElse
            (
                Expression.Equal(sourceExpression, Expression.Constant(null)),
                Expression.Assign(dstParam, Expression.Default(destExpression.Type)),
                Expression.Assign(dstParam,
                    ExpressionBuilder.MapExpression(configurationProvider, profileMap,
                        new TypePair(ContainerDef(sourceExpression.Type), destExpression.Type),
                        Expression.Property(sourceExpression, sourceExpression.Type.GetTypeInfo().GetDeclaredProperty(containerPropertyName)),
                        contextExpression,
                        propertyMap,
                        destExpression
                    )
                )
            ),
            dstParam
        );
    }

    public TypePair GetAssociatedTypes(TypePair initialTypes)
    {
        return new TypePair(ContainerDef(initialTypes.SourceType), initialTypes.DestinationType);
    }
}
public class BaseContainerMapper
{
    protected readonly Type genericContainerTypeDefinition;
    protected readonly String containerPropertyName;
    public BaseContainerMapper(Type GenericContainerTypeDefinition, String ContainerPropertyName)
    {
        genericContainerTypeDefinition = GenericContainerTypeDefinition;
        containerPropertyName = ContainerPropertyName;
    }
    protected ParameterExpression DigParameter(Expression e)
    {
        if (e is ParameterExpression pe) return pe;
        if (e is UnaryExpression ue) return DigParameter(ue.Operand);
        throw new ArgumentException("Couldn't find parameter");
    }
    public static Type ContainerDef(Type gen, Type to)
    {
        return new[] { to }.Concat(to.GetInterfaces())
                            .Where(x => x.IsGenericType)
                            .Where(x => gen.IsAssignableFrom(x.GetGenericTypeDefinition()))
                            .Select(x => x.GenericTypeArguments.Single())
                            .FirstOrDefault(); // Hopefully not overloaded!

    }
    protected Type ContainerDef(Type to)
    {
        return ContainerDef(genericContainerTypeDefinition, to);
    }
    protected PropertyInfo Of(Expression expr)
    {
        return expr.Type.GetTypeInfo().GetDeclaredProperty(containerPropertyName);
    }
}


回答3:

This patch allows typeof(void) to be used in CreateMap to express a single parameter that is not contained within a type (i.e. it will match any type) and construct the ITypeConverter as such.

The alternative for an unpatched AutoMapper is to substitute X and Y with object in the below converters. I have added comments to the lines where this would fail. Even ignoring these failings, such an attempt would require reflection and therefore be more complex and less performant.

Mapper.Initialise(cfg =>
{
    cfg.CreateMap(typeof(void), typeof(IContainer<>)).ConvertUsing(typeof(TCf<,>));
    cfg.CreateMap(typeof(IContainer<>), typeof(void)).ConvertUsing(typeof(TCb<,>));
});

class TCf<X, Y> : ITypeConverter<X, IContainer<Y>>
{
    public IContainer<Y> Convert(X source, IContainer<Y> destination, ResolutionContext context)
    {
        if (destination == null)
        {
            // if Y was object we could not create the correct container type
            destination = new Container<Y>(); 
            destination.Configure();
        }
        // if Y was object and source was null, we could not map to the correct type
        destination.Value = context.Mapper.Map<Y>(source);
        return destination;
    }
}

class TCb<X, Y> : ITypeConverter<IContainer<X>, Y>
{
    public Y Convert(IContainer<X> source, Y destination, ResolutionContext context)
    {
        // if X was object and source was null, we could not choose an appropriate default
        var use = source == null ? GetSomeDefault<X>() : source.Value;
        // if Y was object and destination was null, we could not map to the correct type
        return context.Mapper.Map<Y>(use);
    }
}


回答4:

By using ForAllMaps, you can get the source/destination types and provide a closed, fully generic converter type. This doesn't help if you want to directly call -Map<X,IContainer<Y>, but you shouldn't need to.

Mapper.Initialize(c =>
{
    c.CreateMap<model, viewmodel>().ReverseMap();
    c.ForAllMaps((p, mc) =>
    {
        Type st = p.SourceType, sct = GetContained(st);
        Type dt = p.DestinationType, dct = GetContained(dt);
        if (sct != null) mc.ConvertUsing(typeof(TCReverse<,>).MakeGenericType(sct, dt));
        if (dct != null) mc.ConvertUsing(typeof(TCForward<,>).MakeGenericType(st, dct));
    });
});
Mapper.AssertConfigurationIsValid();
Mapper.Map<viewmodel>(new model());
Mapper.Map<model>(new viewmodel());

With the simple converters:

public class TCReverse<X,Y> : ITypeConverter<IContainer<X>, Y>
{
    public Y Convert(IContainer<X> source, Y destination, ResolutionContext context)
    {
        var use = source == null ? default(X) : source.Value;
        return context.Mapper.Map(use, destination);
    }
}

public class TCForward<X,Y> : ITypeConverter<X, IContainer<Y>>
{
    public IContainer<Y> Convert(X source, IContainer<Y> destination, ResolutionContext context)
    {
        if (destination == null)
            destination = new Container<Y>();
        destination.Value = context.Mapper.Map(source, destination.Value);
        return destination;
    }
}

And I used a helper method here:

Type GetContained(Type t)
{
    return t.GetInterfaces()
            .Concat(new[] { t })
            .Where(x => x.IsGenericType)
            .Where(x => typeof(IContainer<>).IsAssignableFrom(x.GetGenericTypeDefinition()))
            .Select(x => x.GenericTypeArguments[0])
            .FirstOrDefault();
}