3

I use ModelMapper to convert Models to DTOs. I have a bunch of default converters for null values that have been registered at the mapper level like this:

        modelMapper.addConverter(new Converter<String, String>() {
            @Override
            public String convert(MappingContext<String, String> context) {
                if (context.getSource() == null) {
                    return "global null converter was here";
                }
                return context.getSource();
            }
        });

This works fine with simple mapping when the properties name are the same on both side of the conversion. The converter is used to handle null values as expected.

Now if I need to do a more complex conversions with different properties name by using .map(getter, setter) on the type map, the global converters are not called anymore. I don't want the global converters to be discarded when configuring the typemap.

How can I fix that ?

Here is a sample code (with lombok for code brevity) that use ModelMapper 2.3.8, the today's latest version:

@Data @AllArgsConstructor @NoArgsConstructor class A { String a; String b;}

@Data @AllArgsConstructor @NoArgsConstructor class B { String a; String b; }

@Data @AllArgsConstructor @NoArgsConstructor class C { String x; String y;}

public class MapperTestCase {
    

    public static void main(String[] args) throws IOException {

        A a = new A("aaa", "bbb");
        
        ModelMapper modelMapper = new ModelMapper();
        final TypeMap<A, B> AtoBTypeMap = modelMapper.createTypeMap(A.class, B.class);
        B b = AtoBTypeMap.map(a);
        System.out.println("conversion with no converter A -> B: " + a + " -> " + b);

        a = new A(null, null);
        b = AtoBTypeMap.map(a);
        System.out.println("conversion with no converter A -> B: " + a + " -> " + b);

        // Add a global/fallback converter that should convert all null String values.
        modelMapper.addConverter(new Converter<String, String>() {
            @Override
            public String convert(MappingContext<String, String> context) {
                if (context.getSource() == null) {
                    return "global null converter was here";
                }
                return context.getSource();
            }
        });            

        final TypeMap<B, A> BtoATypeMap = modelMapper.typeMap(B.class, A.class);
        a = BtoATypeMap.map(b);
        System.out.println("conversion with global converter B -> A: " + b + " -> " + a);
        
        // add a local converter for the B to C type mape only
        BtoATypeMap.addMappings(mapper -> mapper.using(ctx -> {
            if (ctx.getSource() == null) {
                return "local converter was here";
            } else return ctx.getSource();
        }).map(B::getA, (w, x) -> w.setA(String.valueOf(x))));

        // in this conversion both converter (global and local) should be used
        a = BtoATypeMap.map(b);
        System.out.println("conversion with global and local converter B -> A: " + b + " -> " + a);

        // a new typeMap that will transform a B into a C, mapping B::a to C::x and B::b to C::y
        final TypeMap<B, C> BtoCTypeMap = modelMapper.typeMap(B.class, C.class);
        
        // a local converter for this type map
        BtoCTypeMap.addMappings(mapper -> mapper.using(ctx -> {
            if (ctx.getSource() == null) {
                return "local converter was here";
            } else return ctx.getSource();
        }).map(B::getA, (w, x) -> w.setX(String.valueOf(x))));
        
        BtoCTypeMap.addMapping(B::getB, C::setY);
        // first a conversion with a B instance without null values, works as expected
        b = new B("some", "data");
        C c = BtoCTypeMap.map(b);
        System.out.println("conversion with global and local converter B -> C: " + b + " -> " + c);

        // now a conversion with a B instance wirth null values, the local converer will be used, but not the global one defined at the mapper level. Why ?
        b = new B();
        c = BtoCTypeMap.map(b);
        System.out.println("conversion with global and local converter B -> C: " + b + " -> " + c);
    }
}

The output is:

conversion with no converter A -> B: A(a=aaa, b=bbb) -> B(a=aaa, b=bbb)
conversion with no converter A -> B: A(a=null, b=null) -> B(a=null, b=null)
conversion with global converter B -> A: B(a=null, b=null) -> A(a=global null converter was here, b=global null converter was here)
conversion with global and local converter B -> A: B(a=null, b=null) -> A(a=local converter was here, b=global null converter was here)
conversion with global and local converter B -> C: B(a=some, b=data) -> C(x=some, y=data)
conversion with global and local converter B -> C: B(a=null, b=null) -> C(x=local converter was here, y=null)

The expected output for the last line is C(x=local converter was here, y=global null converter was here)

Guillaume
  • 5,488
  • 11
  • 47
  • 83

3 Answers3

2

I must recognize that I normally use MapStruct or Dozer but, from time to time, I have used ModelMapper.

That been said, I will try to explain the mental model I follow when working with this library: I hope it helps you in understand your problem.

When you define a map between a source and a destination class in ModelMapper, you are actually defining the correspondence, the mappings, between their properties.

There is an implicit mapping that takes place if you do not define an explicit one between one property in the source class and another in the destination class.

This implicit mapping is based on several matching policies but we can safely say for our problem that it is based on property name matching.

If you define a Converter at the ModelMapper level, it will be applied to a property mapping only if one explicit property mapping is not provided, for the following reason: if you define an explicit property mapping between any properties in a TypeMap, by using the methods addMapping or addMappings, the configuration provided for that explicit mapping (source getter and destination setter, converters, pre-converters, post-converters) will be the only that will take place in the mapping process, no matter what you define at a higher mapping level.

You can easily test this fact by debugging your program and see, line by line, how the underlying property mappings are defined by the library.

For that reason I think it is not possible to implement such a global behavior: what you can do is repeat it by possibly implementing a factory method as suggested in other answer or better, by creating specific Converter classes that you can instantiate and set as converters (or, maybe, post-converters, in your use case) of every TypeMap and property mapping in which it is required.

There is an excelent post here in stackoverflow that will provide you a great and better explanation about what is happening under the hood when you use ModelMapper.

jccampanero
  • 50,989
  • 3
  • 20
  • 49
  • Thanks @jccampanero, this is quite an helpful explanation. I think I was confused by the ModelMapper.addConverter that really looks like a way to add global/fallback converter. – Guillaume Sep 04 '20 at 07:15
2

If You wanna create general propertyConverter you can try someshing like this

        Converter<String, String> stringPropertyConverter = new Converter<String, String>() {
        @Override
        public String convert(MappingContext<String, String> context) {
            if (context.getSource() == null) {
                return "global null converter was here";
            }
            return context.getSource();
        }
    };

    ModelMapper modelMapper = new ModelMapper() {
        @Override
        public <S, D> TypeMap<S, D> typeMap(Class<S> sourceType, Class<D> destinationType) {
            TypeMap<S, D> typeMap = super.typeMap(sourceType, destinationType);
            typeMap.setPropertyConverter(stringPropertyConverter);
            return typeMap;
        }

    }; 

Generally problem in order convertors which using in mapping process. At first modelMapper define convertor for your class, on the next step it search suitable convertor for field of class. In the firs case your converters placed in order

"TypeMap[String -> String]"
"TypeMap[B -> A]"
"TypeMap[A -> B]"

in the second case

"TypeMap[B -> C]"
"TypeMap[String -> String]"
"TypeMap[B -> A]"
"TypeMap[A -> B]"

and convertor B to C is suitable convertor for any of your fields in your class.

  • That's how I finally resolved my issue by using a factory that register my fallback converter by using typeMap.setPropertyConverter(). I think that the ModelMapper.addConverter is misleading and give me false impressions ! – Guillaume Sep 04 '20 at 07:10
1

I think i found the solution :

it's because your class C have different attribute names than your class A and B. If you rename x to a and y to b, the output will be good.

Your question now is "why is this working like that", simply because modelMapper apply the converter only if the name between the 2 objects are the same. I don't think modelMapper provide a solution for a real "global" converter by ignoring attributes name as you wish.

About your code, i think you should use Java 8 functionality :

modelMapper.addConverter(new Converter<String, String>() {
    @Override
    public String convert(MappingContext<String, String> context) {
        if (context.getSource() == null) {
             return "global null converter was here";
        }
        return context.getSource();
    }
});

can be rewrite :

modelMapper.addConverter(context -> context.getSource() == null ? "global null converter was here" : context.getSource());

// or much better because you extract your mapping logic :

modelMapper.addConverter(mySuperConverter());

private static Converter<String, String> mySuperConverter() {
    return context -> context.getSource() == null ? "global null converter was here" : context.getSource();
}

Gauthier T.
  • 556
  • 1
  • 4
  • 16
  • The global type based conversion is triggered when properties have the same name. But it should still be available as a fallback solution for all conversion.. I still find unclear why the global converter is discarded in this case: what the point of this converter then ? As a workaround we decided to use a global converter at the TypeMap level that mimic this type based converter. For the lambdas, the Generic types of the converter are lost when using directly a lambda, so your tip to go through a factory is a nice way to go ! – Guillaume Sep 03 '20 at 09:54