ModelMapper force use converter on parent type

119 views Asked by At

I'm using ModelMapper to convert data between some apis.
Some of inner fields in api A are String and need to be converted to inner fields of B that are either Integer, Long, Short, Double or Byte.

Is it possible to create 1 single converter that would be applied to all fields?

If I do:

modelMapper.addConverter(new Converter<String, Integer>() {
         @Override
         public Integer convert(MappingContext<String, Integer> context) {
            if (StringUtils.isBlank(context.getSource())) {
               return null;
            }
            long value = Long.parseLong(context.getSource().replaceAll("\\D", ""));

            return value > Integer.MAX_VALUE || value < Integer.MIN_VALUE ? null : (int) value;
         }
      });
      modelMapper.addConverter(new Converter<String, Long>() {
         @Override
         public Long convert(MappingContext<String, Long> context) {
            if (StringUtils.isBlank(context.getSource())) {
               return null;
            }
            return Long.parseLong(context.getSource().replaceAll("\\D", ""));
         }
      });
      modelMapper.addConverter(new Converter<String, Double>() {
         @Override
         public Double convert(MappingContext<String, Double> context) {
            if (StringUtils.isBlank(context.getSource())) {
               return null;
            }
            return Double.parseDouble(context.getSource().replaceAll("^[\\d.]", ""));
         }
      });

this works as I expect but it is a lot of code repetition.

But if I do:

  modelMapper.addConverter(new Converter<String, Number>() {
     @Override
     public Numberconvert(MappingContext<String, Number> context) {
        if (StringUtils.isBlank(context.getSource())) {
           return null;
        }
        return Double.parseDouble(context.getSource().replaceAll("^[\\d.]", ""));
     }
  });

this converter won't even be called during the data transformation.

Besides the string -> number transformation, I also need transformation from int -> short, long -> int, long -> short, etc.

So in the end I would have around 1000 lines just of these boilerplate repetition code...

"Ah, but there is default converters on modelmapper you don't need to redefine them" - Modelmapper converters won't work for me because they throw exception on any small data problem [such as int value is to big for short], and I need to simply ignore those errors and place null.

How to achieve this?

2

There are 2 answers

0
Chaosfire On

Having one 'parent' converter, that handles everything, would make it something like a 'god' converter - doing too many things and having too much responsibilities. Not to mention way too much code in one place.

I would suggest the template method pattern to avoid the duplication:

public abstract class NumberConverter<S, D extends Number> implements Converter<S, D> {

  @Override
  public D convert(MappingContext<S, D> context) {
    Object source = context.getSource();
    if (source == null) {
      return null;
    }
    String sourceString = source.toString();
    if (sourceString.isBlank()) {
      return null;
    }
    sourceString = sourceString.replaceAll("\\D", "");
    //other work on 'sourceString' if required
    try {
      return convertToNumber(sourceString);
    } catch (Exception exc) {
      //maybe some logging
      return null;
    }
  }

  protected abstract D convertToNumber(String source);
}

This is taking advantage of the fact that Number.parseNumber methods throw NumberFormatException, if the string represents a number, which does not fit in the range of the type.

You can extend this base class for all number types and register manually all converters, or you can follow the dynamic registration example bellow.

Since there can be a lot of number -> number combinations, you can register the converters dynamically.

ModelMapper mapper = new ModelMapper();

DestinationsHolder holder = new DestinationsHolder();
holder.register(Integer.class, Integer::parseInt);
holder.register(Short.class, Short::parseShort);
//register the rest
    
Map<Class<? extends Number>, Function<String, ? extends Number>> destinations = holder.getDestinations();

for (Map.Entry<Class<? extends Number>, Function<String, ? extends Number>> entry : destinations.entrySet()) {
  //register string to number converters
  mapper.addConverter(new NumberConverter() {
    @Override
    protected Number convertToNumber(String source) {
      return entry.getValue().apply(source);
    }
  }, String.class, entry.getKey());
  //build combinations
  for (Class<? extends Number> source : destinations.keySet()) {
    //register number -> number converters
    if (source.equals(entry.getKey())) {
      //int -> int, short -> short, etc.
      //no need for number -> string -> number conversion
      //return input directly
      mapper.addConverter(new Converter() {
        @Override
        public Object convert(MappingContext context) {
          return context.getSource();
        }
      }, source, entry.getKey());
    } else {
      //int -> short, int -> long, etc.
      mapper.addConverter(new NumberConverter() {
        @Override
        protected Number convertToNumber(String source) {
          return entry.getValue().apply(source);
        }
      }, source, entry.getKey());
    }
  }
}

DestinationsHolder is a class with the purpose to disallow incorrect class to parser function mappings - Integer.class -> Double::parseDouble will result in compilation error.

public class DestinationsHolder {

  private final Map<Class<? extends Number>, Function<String, ? extends Number>> destinations;

  public DestinationsHolder() {
    this.destinations = new HashMap<>();
  }

  public <T extends Number> void register(Class<T> type, Function<String, T> pasrserFunction) {
    destinations.put(type, pasrserFunction);
  }

  public Map<Class<? extends Number>, Function<String, ? extends Number>> getDestinations() {
    return Map.copyOf(destinations);
  }
}

Unit tests:

public class ModelMapperTests {

  private ModelMapper mapper;

  @BeforeEach
  public void setup() {
    ModelMapper mapper = new ModelMapper();
    //add configuration from above
    this.mapper = mapper;
  }

  @ValueSource(strings = {"", "   ", "111111111"})
  @ParameterizedTest
  public void invalidInput_ConvertStringToShort_ShouldReturnNull(String input) {
    Short result = mapper.map(input, Short.class);
    assertNull(result, "Invalid result should have mapped to null");
  }

  @ValueSource(strings = {"11", "125", "1234"})
  @ParameterizedTest
  public void validInput_ConvertStringToShort_ShouldMapCorrectly(String input) {
    Short result = mapper.map(input, Short.class);
    assertEquals(Short.parseShort(input), result, "Incorrect mapping result");
  }

  @ValueSource(ints = {-111111111, 125_392, 111111111})
  @ParameterizedTest
  public void invalidInput_ConvertIntToShort_ShouldReturnNull(Integer input) {
    Short result = mapper.map(input, Short.class);
    assertNull(result, "Invalid result should have mapped to null");
  }

  @ValueSource(ints = {125, 392, 12_345})
  @ParameterizedTest
  public void validInput_ConvertIntToShort_ShouldMapCorrectly(Integer input) {
    Short result = mapper.map(input, Short.class);
    assertEquals(input.shortValue(), result, "Incorrect mapping result");
  }
}
3
Maurice Perry On

I'll complete Chaosfire's answer with a slightly simpler one: instead of defining an abstract class, define a concrete one:

public class StringToNumberConverter<T extends Number>
        implements Converter<String, T> {
    private final Function<String, T> convert;

    public StringToNumberConverter(Function<String, T> convert) {
        this.convert = convert;
    }

    @Override
    public T convert(MappingContext<String, T> context) {
        String source = context.getSource();
        if (StringUtils.isBlank(source)) {
            return null;
        }
        try {
            return convert.apply(source);
        } catch (Exception exc) {
            return null;
        }
    }
}

Now, you can register the converters as follows:

    mapper.addConverter(new StringToNumberConverter<>(Integer::parseInt));
    mapper.addConverter(new StringToNumberConverter<>(Long::parseLong));
    mapper.addConverter(new StringToNumberConverter<>(Double::parseDouble));