MapsterMapper / Mapster

A fast, fun and stimulating object to object Mapper
MIT License
4.31k stars 328 forks source link

`Nullable<TSrc>` is not properly mapped to TDest where `TSrc : struct`, `TDest : class` #417

Open foriequal0 opened 2 years ago

foriequal0 commented 2 years ago

With this fixtures,

class Timestamp
{
  public Timestamp()
  {
  }

  public Timestamp(long timestamp)
  {
    this.timestamp = timestamp;
  }

  public long timestamp { get; set; }
}

var config = new TypeAdapterConfig();
config
  .NewConfig<DateTimeOffset, Timestamp>()
  .MapWith(src => new Timestamp(src.ToUnixTimeSeconds());

This code doesn't work as expected.

DateTimeOffset? dt1 = null;
dt1.Adapt<Timestamp>(config); // == null as expected

DateTimeOffset? dt2 = DateTimeOffset.Now;
dt2.Adapt<Timestamp>(config); // new Timestamp(dt2.Value.ToUnixTimeSeconds()) expected, but is new Timestamp();

We mitigated this issue with following code snippet:

/// after all mappings are regstered, call this function
public static void AugmentNullableValueTypeToRefTypeMapping(TypeAdapterConfig config)
{
    foreach (var (tuple, rule) in config.RuleMap)
    {
        var src = tuple.Source;
        var dest = tuple.Destination;

        if (!src.IsValueType || dest.IsValueType)
        {
            continue;
        }

        if (src.IsConstructedGenericType && src.GetGenericTypeDefinition() == typeof(Nullable<>))
        {
            continue;
        }

        var nullableSrc = typeof(Nullable<>).MakeGenericType(src);
        var nullableTuple = new TypeTuple(nullableSrc, dest);
        if (config.RuleMap.ContainsKey(nullableTuple))
        {
            continue;
        }

        if (rule.Settings.ConverterFactory != null)
        {
            config.ForType(nullableSrc, dest)
                .Settings.ConverterFactory = BuildConverterFactory(rule.Settings.ConverterFactory);
        }

        if (rule.Settings.ConverterToTargetFactory != null)
        {
            config.ForType(nullableSrc, dest)
                .Settings.ConverterToTargetFactory = BuildConverterToTargetFactory(rule.Settings.ConverterToTargetFactory);
        }
    }
}

private static Func<CompileArgument, LambdaExpression> BuildConverterFactory(Func<CompileArgument, LambdaExpression> converterFactory)
{
    return (CompileArgument args) =>
    {
        // expr: src => f(src);
        var expr = converterFactory(args);
        var src = expr.Parameters[0];
        var destType = expr.ReturnType;

        // nullableSrc.HasValue ? expr(nullableSrc.Value) : default;
        var nullableSrcType = typeof(Nullable<>).MakeGenericType(src.Type);
        var nullableSrc = Expression.Parameter(nullableSrcType);

        var hasValue = Expression.Property(nullableSrc, "HasValue");

        var value = Expression.Property(nullableSrc, "Value");
        var invoke = Expression.Invoke(expr, value);

        var defaultValue = Expression.Default(destType);

        var condition = Expression.Condition(hasValue, invoke, defaultValue);

        // nullableSrc => nullableSrc.HasValue ? expr(nullableSrc.Value) : default;
        return Expression.Lambda(condition, nullableSrc);
    };
}

private static Func<CompileArgument, LambdaExpression> BuildConverterToTargetFactory(Func<CompileArgument, LambdaExpression> converterFactory)
{
    return (CompileArgument args) =>
    {
        // expr: (src, dest) => f(src);
        var expr = converterFactory(args);
        var src = expr.Parameters[0];
        var dest = expr.Parameters[1];

        // nullableSrc.HasValue ? expr(nullableSrc.Value) : default;
        var nullableSrcType = typeof(Nullable<>).MakeGenericType(src.Type);
        var nullableSrc = Expression.Parameter(nullableSrcType);

        var hasValue = Expression.Property(nullableSrc, "HasValue");

        var value = Expression.Property(nullableSrc, "Value");
        var rewritten = RewritingVisitor.Rewrite(expr.Body, src, value);

        var @defaultValue = Expression.Default(dest.Type);

        var condition = Expression.Condition(hasValue, rewritten, @defaultValue);

        // (nullableSrc, dest) => nullableSrc.HasValue ? expr(nullableSrc.Value, dest) : default;
        return Expression.Lambda(condition, nullableSrc, dest);
    };
}

private sealed class RewritingVisitor : ExpressionVisitor
{
    private readonly Expression from;
    private readonly Expression to;

    public static TExpr Rewrite<TExpr>(TExpr expr, Expression from, Expression to)
        where TExpr : Expression
    {
        var visitor = new RewritingVisitor(from, to);
        return (TExpr)visitor.Visit(expr)!;
    }

    private RewritingVisitor(Expression from, Expression to)
    {
        this.from = from;
        this.to = to;
    }

    public override Expression? Visit(Expression? node)
    {
        if (node == from)
        {
            return to;
        }

        return base.Visit(node);
    }
}

edit: I've fixed typos in the example.

OFark commented 2 years ago

You also need a .NewConfig<DateTimeOffset?, Timestamp>() What would it do with .MapWith(src => new Timestamp(src.ToUnixTimeSeconds()); if src was null?

foriequal0 commented 2 years ago

I expect it to behave same as reference types. If DateTimeOffset were a reference type, then dt2.Adapt<Timestamp>(config) wouldn't return new Timestamp()

OFark commented 2 years ago

Well, I was searching for a better solution to a problem I was having with StronglyTypedIds structs and Mapster. I was trying to create a Source Generator to map the Id's automatically. What I found was a nullable version of a type was treated separately to the type, which makes sense when you do what you're doing. It would throw if you tried to do src.ToUnixTimeSeconds() when src was null.