ifelseelif
ifelseelif

Reputation: 341

AutoMapper: Problem with mapping records type

I am mapping with automapper 10.1.1 in c# 9 from this class

public record BFrom 
{
    public Guid Id { get; init; }
    public Guid DbExtraId { get; init; }
}

into this

public record ATo(Guid Id, Guid ExtraId) : BaseTo(Id);

And have the following configuration

CreateMap<BFrom, ATo>()
    .ForMember("ExtraId", opt => opt.MapFrom(src => src.DbExtraId))
    .ReverseMap();

But I have a problem when I am trying to map it throws an exception with a message that needs to have a constructor with 0 args or only optional args. Is it possible to fix this problem without changing the record types?

Upvotes: 24

Views: 19259

Answers (5)

realbart
realbart

Reputation: 3965

I didn't see the answer that fixed the problems for me: I simply replaced "ForMember" by "ForCtorParm".

You need to provide the constructor parameter as a string, so that becomes:

CreateMap<BFrom, ATo>()
    .ForCtorParam("ExtraId", opt => opt.MapFrom(src => src.DbExtraId))
    .ForCtorParam("Id", opt => opt.MapFrom(src => default(Guid)));

There's a few things to notice:

  • You need to provide all parameters, otherwise the (implicitly created) constructor cannot be called.
  • The "Ignore" method is not there anymore. You cannot choose to ignore a constructor parameter.
  • ReverseMap won't work: once compiled, there's a constructor with parameter names that just happen to have the same names and types as the properties. But at that stage, there's no way for automapper to know this is the case. Records are just compiled to classes (with some special attributes)

You could make your life easier by taking advantage of the fact that in practice the generated constructor parameters and the properties always have the same names/types. You can add an extension method.

   public static class AutoMapperExtensions
    {
        public static IMappingExpression<TSource, TDestination> ForCtorParm<TSource, TDestination, TMember>(
               this IMappingExpression<TSource, TDestination> mappingExpression,
               Expression<Func<TDestination, TMember>> destinationMember,
               Action<ICtorParamConfigurationExpression<TSource>> paramOptions)
        {
            var memberInfo = FindProperty(destinationMember);
            var memberName = memberInfo.Name;
            return mappingExpression
                .ForCtorParam(memberName, paramOptions);
        }

        private static MemberInfo FindProperty(LambdaExpression lambdaExpression)
        {
            Expression expressionToCheck = lambdaExpression.Body;
            while (true)
            {
                switch (expressionToCheck)
                {
                    case MemberExpression { Member: var member, Expression: { NodeType: ExpressionType.Parameter or ExpressionType.Convert } }:
                        return member;
                    case UnaryExpression { Operand: var operand }:
                        expressionToCheck = operand;
                        break;
                    default:
                        throw new ArgumentException(
                            $"Expression '{lambdaExpression}' must resolve to top-level member and not any child object's properties. You can use ForPath, a custom resolver on the child type or the AfterMap option instead.",
                            nameof(lambdaExpression));
                }
            }
        }
    }

This allows you to do things a bit more type safe:

CreateMap<BFrom, ATo>()
    .ForCtorParam(dest => dest.ExtraId, opt => opt.MapFrom(src => src.DbExtraId))
    .ForCtorParam(dest => dest.Id, opt => opt.MapFrom(src => default(Guid)));

Upvotes: 1

Cowboy
Cowboy

Reputation: 1066

Use a more static approach.. we know the record constructor name..

public record ATo(Guid Id, Guid ExtraId) : BaseTo(Id);

CreateMap<BFrom, ATo>()
    .ForMember(nameof(ATo.Extra), opt => opt.MapFrom(src => src.DbExtraId))
    .ReverseMap();

Explanation / Deepish dive

Records are really just syntax sugar for a concept that's been around forever, that is they offer immutable properties. Records actually make AutoMapper with the old concept easier, and safer because with the new syntax we know what the property names will be, i.e record Example(string SomeProperty). The compiler will just create a constructor like so,

public class Example
{
    public string SomeProperty { get; init; }         
    Example(string SomeProperty) 
    {
        this.SomeProperty = SomeProperty;
    }
}

Making an empty constructor wont help you because the properties are init only, and you cant really use object initializer because you have to use the constructor... and well object initializers are a special case for init properties, that is they allow the properties to change even though object initializers are really just syntax sugar themselves. There is not really a way via reflection to use object initializers, so that loophole is gone for libraries like AutoMapper, the loophole being.. new Example(default){SomeProperty = "SomeValue"}.

Now of course you can use reflection to change readonly/private/internal properties, which I am not sure if AutoMapper supports or not, I hope it doesn't because its not a very good thing to do for reasons well documented on the internet.

Upvotes: 1

froeschli
froeschli

Reputation: 2880

What you need, is to specify the constructor-parameter by name in your mapping profile like so public AToProfile() => CreateMap<BFrom, ATo>().ForCtorParam(ctorParamName: "ExtraId", m => m.MapFrom(s => s.DbExtraId)).ReverseMap(); That way, AutoMapper will know, from where the value should be taken and will map accordingly.

public class StackoverflowQuestionTest
{
    [Fact]
    public void BFrom_MapTo_ATo()
    {
        // Arrange
        IConfigurationProvider configuration = new MapperConfiguration(cfg => cfg.AddProfile<AToProfile>());
        var source = new BFrom {Id = Guid.NewGuid()};

        // Act
        var target = new Mapper(configuration).Map<ATo>(source);

        // Assert
        target.Id.Should().Be(source.Id);
    }


}

public record BaseTo (Guid Id); // this is an assumption, as you did not specify BaseTo in you question

public record BFrom
{
    public Guid Id { get; init; }
    public Guid DbExtraId { get; init; }
}

public record ATo(Guid Id, Guid ExtraId) : BaseTo(Id);

public class AToProfile : Profile
{
    public AToProfile() =>
        CreateMap<BFrom, ATo>()
            .ForCtorParam(ctorParamName: "ExtraId", m => m.MapFrom(s => s.DbExtraId))
            .ReverseMap();
}

Upvotes: 14

Christian Genne
Christian Genne

Reputation: 210

I had the same issue, and ended up creating this extension method to solve it:

public static class AutoMapperExtensions
{
    public static IMappingExpression<TSource, TDestination> MapRecordMember<TSource, TDestination, TMember>(
        this IMappingExpression<TSource, TDestination> mappingExpression,
        Expression<Func<TDestination, TMember>> destinationMember, Expression<Func<TSource, TMember>> sourceMember)
    {
        var memberInfo = ReflectionHelper.FindProperty(destinationMember);
        string memberName = memberInfo.Name;
        return mappingExpression
            .ForMember(destinationMember, opt => opt.MapFrom(sourceMember))
            .ForCtorParam(memberName, opt => opt.MapFrom(sourceMember));
    }
}

Then you simply use it like this:

CreateMap<BFrom, ATo>()
    .MapRecordMember(a => a.ExtraId, src => src.DbExtraId)
    .ReverseMap();

and it will take care of registering both constructor and member so that you don't run into issues like these.

Upvotes: 9

Athanasios Kataras
Athanasios Kataras

Reputation: 26352

Try

CreateMap<BFrom, ATo>().DisableCtorValidation()
    .ForMember("ExtraId", opt => opt.MapFrom(src => src.DbExtraId))
    .ReverseMap()

Upvotes: 4

Related Questions