domingo, 23 de junio de 2013

Un poquito más de AutoMapper, por favor

Qué es AutoMapper y una introducción al mismo está explicado perfectamente en el post AutoMapper (I) Flattening de Luis Ruiz Pavón. Asumiendo que ya te has leído ese post (si no lo has hecho, corre a leerlo y luego vuelve aquí), en este post lo que quiero es contar algunos otros conceptos que podrían sernos de utilidad a la hora de realizar esa tediosa e ingrata tarea que es la de mapear objetos.

Partiendo de estos tipos, iremos viendo distintos usos de AutoMapper:

class Jugador

{

    public string Nombre { get; set; }

    public int Dorsal { get; set; }

    public string Posicion { get; set; }

}

 

class Jugador2

{

    public string Nombre { get; set; }

    public int Dorsal { get; set; }

}

En su versión más básica, sólo son necesarios los siguientes pasos para utilizar AutoMapper:

  • Crear un mapa donde especificaremos un tipo origen y un tipo destino.
    • Sin mapa no hay conversión posible.
    • Aquí podría ayudarnos el método ReverseMap para no tener que crear 2 mapas sino con uno sólo crear tanto el mapa de A a B como el mapa de B a A
  • Mapear un objeto origen a un tipo destino.

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>();

var jugador = new Jugador()

    {

        Nombre = "Sergio",

        Dorsal = 10,

        Posicion = "Delantero"

    };

var jugador2 = AutoMapper.Mapper.Map<Jugador, Jugador2>(jugador);

Ahora jugador2 tiene los siguientes datos:

image

Como vemos, el comportamiento predeterminado de AutoMapper es encontrar propiedades coincidentes por el nombre y asignarlas. Nada más y nada menos. En realidad, esto responde al nombre de Flatenning, pero lo dicho… en el post AutoMapper (I) Flattening está muy bien explicado y con casos más avanzados.

Aunque luego veremos que es configurable, en principio si una propiedad existe en origen y no en destino o viceversa, no pasa nada, simplemente se pasa por alto (en nuestro ejemplo anterior la propiedad Posicion existe en Jugador pero no en Jugador2).

Algo obvio si leemos la documentación (yo no hago y así me va) es que distintas sobrecargas del método Map tienen distintos comportamientos. Así por ejemplo Mapper.Map<T, T1>(instancia) devuelve una nueva instancia con el resultado del mapeo mientras que, Mapper.Map(instancia1, instancia2) mapea de una instancia a otra. Avisado quedas.

Otro punto muy importante de AutoMapper es el módulo de Projection. Con la proyección podremos configurar ciertos aspectos del mapeo que nos ayudarán a resolver casos concretos. Por ejemplo, si cambiamos el tipo de Jugador.Dorsal a String, todo seguirá funcionando porque AutoMapper sabe convertir de String a int (el tipo destino en Jugador2.Dorsal). Sin embargo, si ahora asignamos a Jugador.Dorsal algo que no sea un número, AutoMapper fallará (lógicamente no habrá podido convertir ese valor a número):

var jugador = new Jugador()

    {

        Nombre = "Sergio",

        Dorsal = "Sin dorsal",

        Posicion = "Delantero"

    };

var jugador2 = AutoMapper.Mapper.Map<Jugador, Jugador2>(jugador);

image

Para solucionar esta situación tenemos que utilizar la proyección. Por ejemplo, podemos utilizar el método Condition para evaluar si la propiedad Dorsal tiene o no que mapearse:

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

    ForMember(p => p.Dorsal, opt => opt.Condition(j =>

        {

            int result;

            return int.TryParse(j.Dorsal, out result);

        }));

Ahora “10” se mapeará pero “Sin dorsal” no se mapeará.

Otra opción sería utilizar el método Ignore para expresar que aunque coincida el nombre no queremos mapearlo nunca:

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

    ForMember(p => p.Dorsal, opt => opt.Ignore());

Quizás Dorsal siempre sea un valor fijo, en ese caso utilizaremos UseValue:

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

            ForMember(p => p.Dorsal, opt => opt.UseValue(99));

Otra opción es que sin Dorsal no es numérico devolvamos 99 pero si es numérico devolvamos el mismo Dorsal. Ahora utilizaremos el método MapFrom:

static bool IsNumeric(string value)

{

    int result;

    return int.TryParse(value, out result);

}

 

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

            ForMember(p => p.Dorsal, opt => opt.MapFrom(j =>

                IsNumeric(j.Dorsal) ? Convert.ToInt32(j.Dorsal) : 99));

También podemos encadenar llamadas para un mismo miembro puesto que AutoMapper es una API fluida. Por ejemplo, si Dorsal es válido sumarle 10:

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

    ForMember(p => p.Dorsal, opt => opt.Condition(j =>

    {

        int result;

        return int.TryParse(j.Dorsal, out result);

    })).

    ForMember(p => p.Dorsal, opt => opt.MapFrom(j =>

        Convert.ToInt32(j.Dorsal) + 10));

Como podrás imaginar hay un montón de funciones para configurar el mapeo. A mí personalmente me parecen interesantes las funciones BeforeMap y AfterMap que permite ejecutar código antes y después de que AutoMapper haya hecho el mapeo:

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

    ForMember(p => p.Dorsal, opt => opt.Ignore()).

    AfterMap((j, j2) =>

    {

        j2.Dorsal = 99;

    });

Otra opción cuando se complica el mapeo de un cierto valor es crear un Custom Value Resolver para configurar el valor destino. Para ello crearemos una clase que herede de la clase abstracta ValueResolver<TSource, TDestination> y sobrescribiremos el método ResolveCore, después y en el mapa configuraremos que utilice este nuevo Custom Value Resolver para resolver el valor de un miembro:

public class MyCustomValueResolver : ValueResolver<Jugador, int>

{

    protected override int ResolveCore(Jugador source)

    {

        int result;

        if (int.TryParse(source.Dorsal, out result))

        {

            return result;

        }

        return 99;

    }

}

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

    ForMember(p => p.Dorsal, opt => opt.ResolveUsing<MyCustomValueResolver>());

Además de Custom Value Resolvers también existe el concepto de Custom Type Converters que sirven para configurar como se mapeará de un tipo a otro. Por ejemplo, quizás queramos que en nuestros DTO/ViewModels las fechas tengan un formato concreto o simplemente queremos tomar el control absoluto del mapeo de Jugador a Jugador2. En cualquier caso, será necesario crear una clase que implemente la interface ITypeConverter<TSource, TDestination> y después utilizar el método ConvertUsing para especificar a AutoMapper como queremos realizar el mapeo:

public class MyCustomTypeConverter : ITypeConverter<Jugador, Jugador2>

{

    public Jugador2 Convert(ResolutionContext context)

    {

        var jugador = (Jugador)context.SourceValue;

        var jugador2 = new Jugador2

            {

                Nombre = jugador.Nombre

            };

        int result;

        if (int.TryParse(jugador.Dorsal, out result))

        {

            jugador2.Dorsal = result;

        }

        return jugador2;

    }

}

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

    ConvertUsing<MyCustomTypeConverter>();

Para terminar, me gustaría volver a hablar sobre el tratamiento de las propiedades que existen en destino pero no en origen. Inicialmente AutoMapper no se quejará si una propiedad de destino no existe en origen. Sin embargo, existe el método AssertConfigurationIsValid que está entendido para ser utilizado dentro un test unitario y validar la configuración. Validar la configuración sí se quejará si una propiedad de destino no existe en origen (siempre y cuando no se haya ignorado o se haya hecho algún otro tratamiento especial con la propiedad). Por ejemplo, si agregamos una propiedad Reserva (bool) a Jugador2 y ejecutamos AssertConfigurationIsValid, ahora tendremos el siguiente error porque esta propiedad no existe en la clase Jugador.

Unmapped members were found. Review the types and members below.

Add a custom mapping expression, ignore, add a custom resolver, or modify the source/destination type

==================================================

Jugador -> Jugador2 (Destination member list)

PostAutoMapper.Jugador -> PostAutoMapper.Jugador2 (Destination member list)

--------------------------------------------------

Reserva


Si optamos por validar la configuración podríamos utilizar un método de extensión llamado
IgnoreAllNonExisting que hace que todas las propiedades que no existen en origen pero sí en destino sean ignoradas.

AutoMapper.Mapper.CreateMap<Jugador, Jugador2>().

    IgnoreAllNonExisting();

Por último, si quieres organizar mejor los mapas (porque por ejemplo quieres centralizarlos o reutilizarlos) la mejor idea es seguir la convención propuesta por el mismo AutoMapper https://github.com/AutoMapper/AutoMapper/wiki/Configuration

Un saludo!