cezarypiatek / MappingGenerator

:arrows_counterclockwise: "AutoMapper" like, Roslyn based, code fix provider that allows to generate mapping code in design time.
https://marketplace.visualstudio.com/items?itemName=54748ff9-45fc-43c2-8ec5-cf7912bc3b84.mappinggenerator
MIT License
1.03k stars 120 forks source link

Feature request: provide custom mapping for certain types #119

Closed jorisdebock closed 4 years ago

jorisdebock commented 4 years ago

In some cases the generated mapping code is not valid due different types.

for example, these two types

public class Hello
{
    public DateTime DateTime { get; set; }
    public TimeSpan TimeSpan { get; set; }
}
public class HelloReply 
{
    public Timestamp DateTime { get; set; }
    public Duration TimeSpan { get; set; }
}

will create the following (invalid) mapping

new HelloReply()
{
    DateTime = hello.DateTime,
    TimeSpan = hello.TimeSpan
};

Can we extend the MappingInterface like so?

namespace MappingGenerator.OnBuildGenerator
{
    [AttributeUsage(AttributeTargets.Interface)]
    [Conditional("CodeGeneration")]
    public class MappingInterface : Attribute
    {
        public MappingInterface(params System.Type[] typeMappers)
        {
            TypeMappers = typeMappers;
        }

        public System.Type[] TypeMappers { get; }
    }
}

providing a custom type mapper

    public static class ProtoTimeMapper
    {
        public static Timestamp MapDateTime(DateTime dateTime) => dateTime.ToTimestamp();

        public static Timestamp MapDateTimeOffset(DateTimeOffset dateTimeOffset) => dateTimeOffset.ToTimestamp();

        public static Duration MapTimeSpan(TimeSpan timespan) => timespan.ToDuration();
    }

using like so

    [MappingInterface(typeof(ProtoTimeMapper))]
    public interface IGreeterServiceMapper
    {
        HelloReply MapReplyHello(Hello hello);
    }

and the expected results be like

new HelloReply()
{
    DateTime = ProtoTimeMapper.MapDateTime(hello.DateTime),
    TimeSpan = ProtoTimeMapper.MapTimeSpan(hello.TimeSpan)
};

given that:

cezarypiatek commented 4 years ago

Maybe I should always wrap those invalid mapping in MapAToB method invocation and those missing methods should be provided as partial methods?

jorisdebock commented 4 years ago

That might even be better yes, then its not only for "on build", like so?

generated code

    public partial class GreeterServiceMapper
    {
       public HelloReply MapReplyHello(Hello hello)
       {
             return new HelloReply()
            {
                 DateTime = MapDateTime(hello.DateTime),
                 TimeSpan = MapTimeSpan(hello.TimeSpan)
           };
       }
       public HelloReply MapReplyHello2(Hello hello)
       {
             return new HelloReply()
            {
                 DateTime = MapDateTime(hello.DateTime),
                 TimeSpan = MapTimeSpan(hello.TimeSpan)
           };
       }
       public partial Timestamp MapDateTime(DateTime dateTime);
       public partial Duration MapTimeSpan(TimeSpan timeSpan);
    }

implemented partial methods

    public partial class GreeterServiceMapper
    {
       public partial Timestamp MapDateTime(DateTime dateTime)  => dateTime.ToTimestamp();
       public partial Duration MapTimeSpan(TimeSpan timeSpan)  => timeSpan.ToDuration();
    }

in case there are multiple invalid of the same source to target types it should only generate one partial method.

cezarypiatek commented 4 years ago

I need to verify if this is easy to implement and I will let know.

UPDATE: Those extra methods cannot be marked as partial because the partial method has to return void - partial method is more like "optional" method. Anyway, the basic concept should work.

jorisdebock commented 4 years ago

One thing that might not work with the partial class solution is the following

for timespan it actually currently generates the following mapping, which does compile but is actually incorrect behavior.

TimeSpan = new Google.Protobuf.WellKnownTypes.Duration()
{
    Seconds = hello.TimeSpan.Seconds
},

Providing custom mapping upfront, which should be the preferred mapping to use does solve this or some other way to tell the mapping generator that this should be a partial method.

What do you think about this example?

cezarypiatek commented 4 years ago

I can try to combine both ideas by providing configuration options:

namespace MappingGenerator.OnBuildGenerator
{
    [AttributeUsage(AttributeTargets.Interface)]
    [Conditional("CodeGeneration")]
    public class MappingInterface : Attribute
    {
        public MappingInterface(params System.Type[] typeMappers)
        {
            TypeMappers = typeMappers;
        }

        public System.Type[] TypeMappers { get; }
        public bool UsePartialClassMethodsForMissingMappings {get; set;}
    }
}

Not sure what's the right name for this flag but I hope you get the gist.

cezarypiatek commented 4 years ago

@wazowsk1 good news, I've just implemented your idea for VS plugin - check my last commit referenced for this issue. I think adding this feature for OnBuildGenerator should be now quite easy.

cezarypiatek commented 4 years ago

I've just released MappingGenerator.OnBuildGenerator.1.15.380 with the requested feature. I've used the named parameter instead of the custom constructor to make the MappingInterfaceAttribute open for further extensions. A sample usage:

using System;
using System.Collections.Generic;
using System.Text;
using System.Collections.ObjectModel;
using MappingGenerator.OnBuildGenerator;

namespace MappingGenerator.Test.MappingGenerator.TestCaseData
{
    [MappingInterface(CustomStaticMappers = new []{typeof(CustomMappers)})]
    public interface ISampleMapper
    {
        UserDTO Map(UserEntity entity);
    }

 public class CustomMappers
    {
        public static AddressDTO MapAddress(AddressEntity addressEntity)
        {
            return new AddressDTO()
            {
                City = addressEntity.City,
                ZipCode = addressEntity.ZipCode,
                Street = addressEntity.Street,
                FlatNo = addressEntity.FlatNo,
                BuildingNo = addressEntity.BuildingNo
            };
        }
    }

Please update MappingGenerator.OnBuildGenerator and let me know if everything works as you expected.

jorisdebock commented 4 years ago

I have tested it with my test project and bigger project and it is working as expected 👍

without providing a custom mapper

                DateTime = hello.DateTime,
                TimeSpan = new Duration()
                {
                    Seconds = hello.TimeSpan.Seconds
                }

with providing a custom mapper

                DateTime = GrpcService1.CustomMappers.ProtoTimeMapper.MapDateTime(hello.DateTime),
                TimeSpan = GrpcService1.CustomMappers.ProtoTimeMapper.MapTimeSpan(hello.TimeSpan)
cezarypiatek commented 4 years ago

Thanks for testing