Get an estimate
Krzysztof
2021/05/17
ModelMapper is a great library for object mapping in java. With simple objects there is no need to configure anything. You just have to ensure field names are the same and library will handle the rest. The issue arises with nested objects you have to flatten or project.
ModelMapper documentation provides two solutions to this problem. Either create PropertyMap or use Loose Matching strategy. The latter one is not ideal, as it can easily lead to ambiguities. When it comes to PropertyMaps, you have to add them to modelMapper before using them. In this tutorial we will create layer on top of modelMapper that will setup all necessary mappings for us.
Our solution requires use of DTO interface which has updateModelMapper method. We will be overriding this method whenever we need to add mappings.
public interface DTO { default ModelMapper updateModelMapper(ModelMapper mapper, MappingUtils utils){ return mapper; } }
This class is a service that will be responsible for mapping objects. For creating ModelMapper and adding mappings we have functions getMapper and updateMapping.
public <T extends DTO> ModelMapper getMapper(Class<T> target){ ModelMapper modelMapper = new ModelMapper(); modelMapper.getConfiguration() .setFieldAccessLevel(Configuration.AccessLevel.PRIVATE) .setFieldMatchingEnabled(true) .setPropertyCondition(context -> !(context.getSource() instanceof PersistentCollection) ); return updateMapping(modelMapper, target); } public <T extends DTO> ModelMapper updateMapping(ModelMapper mapper, Class<T> dto){ try { Constructor<T> constructor = dto.getConstructor(); T instance = constructor.newInstance(); return instance.updateModelMapper(mapper, this); }catch (NoSuchMethodException | InstantiationException | IllegalAccessException | InvocationTargetException e){ throw new MappingException(dto.getName()); } }
Both functions have Destination class that implements DTO interface as parameter. GetMapper creates ModelMapper instance, sets default configuration and calls updateMapping. UpdateMapping creates instance of destination class and calls updateModelMapper on it.
MappingUtils has also a couple of utility methods for easier mapping:
public <S, T extends DTO> List<T> mapList(List<S> source, Class<T> target){ ModelMapper modelMapper = getMapper(target); return source .stream().map(el -> modelMapper.map(el, target)) .collect(Collectors.toList()); } public T map(S source, Class<T> target){ ModelMapper modelMapper = getMapper(target); return modelMapper.map(source, target); } public <S extends DTO, T> List<T> mapListFromDTO(List<S> source, Class<T> target){ ModelMapper modelMapper = getMapper(source.get(0).getClass()); return source .stream().map(el -> modelMapper.map(el, target)) .collect(Collectors.toList()); } public <S extends DTO, T> T mapFromDTO(S source, Class<T> target){ ModelMapper modelMapper = getMapper(source.getClass()); return modelMapper.map(source, target); }
To show how our MappingUtils work i have created three simple entities: City, Building and Room.
@Entity @Data public class City { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private Long population; @ToString.Exclude @EqualsAndHashCode.Exclude @OneToMany(cascade = CascadeType.ALL, mappedBy = "city") private Set buildings = new HashSet<>(); } @Entity @Data public class Building { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private String number; private String address; @ToString.Exclude @EqualsAndHashCode.Exclude @OneToMany(cascade = CascadeType.ALL, mappedBy = "building") private Set rooms = new HashSet<>(); @ToString.Exclude @EqualsAndHashCode.Exclude @ManyToOne private City city; public void updateRelations(){ rooms.forEach(room -> room.setBuilding(this)); } } @Data @Entity public class Room { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private Integer number; private Integer floor; @ToString.Exclude @EqualsAndHashCode.Exclude @ManyToOne private Building building; }
There will be two simple endpoints: one for downloading list of building and creating new building. Therefore we will need only BuildingRepository:
public interface BuildingRepository extends JpaRepository { @EntityGraph(attributePaths = {"rooms", "city"}) List findAll(); }
Our building response will have a list of rooms so we will start from creating simple RoomDTO.
@Data public class RoomDTO implements DTO { private Long id; private String name; private Integer number; private Integer floor; }
After that, we can proceed to a more complex BuildingDTO:
@Data public class BuildingDTO implements DTO { private Long id; private String name; private String number; private String address; private Integer roomNumber = 0; private List rooms = new ArrayList<>(); private String city; @Override public ModelMapper updateModelMapper(ModelMapper mapper, MappingUtils utils) { mapper.addMappings(buildingMap(utils)); return mapper; } public PropertyMap<Building, BuildingDTO> buildingMap(MappingUtils utils) { return new PropertyMap<Building, BuildingDTO>() { @Override protected void configure() { Converter<Building, Integer> getSize = new AbstractConverter<Building, Integer>() { @Override protected Integer convert(Building building) { return building.getRooms().size(); } }; Converter<List<Building>, List<RoomDTO>> mapRooms = new AbstractConverter<List<Building>, List<RoomDTO>>() { @Override protected List<RoomDTO> convert(Building building) { return utils.mapList(new ArrayList<>(building.getRooms()), RoomDTO.class); } }; using(getSize).map(source, destination.getRoomNumber()); using(mapRooms).map(source, destination.getRooms()); map().setCity(source.getCity().getName()); } }; } }
To create proper mapping we are overriding updateModelMapper from DTO interface. Inside it we are adding our custom PropertyMap. There we have three mappings and two converters. GetSize converter is required to get size of room set. MapRooms is using passed mappingUtils to map rooms to RoomDTO. Before we add nested mapping we have to be sure there is no loop! The mappings themself are straightforward. For simple mapping we are using map function. To apply converter we need to add using before map. Source represents Building and destination represents BuildingDTO.
And here is the controller:
@GetMapping public List<BuildingDTO> getBuildings(){ return mappingUtils .mapList(buildingRepository.findAll(), BuildingDTO.class); }
Again we will start from Room:
@Data public class RoomCreator implements DTO { private String name; private Integer number; private Integer floor; }
and then to BuildingCreator:
@Data public class BuildingCreator implements DTO { private String name; private String number; private String address; private Long cityId; private List rooms; @Override public ModelMapper updateModelMapper(ModelMapper mapper, MappingUtils utils) { mapper.addMappings(buildingMap(utils)); mapper.addMappings(cityMap()); mapper.getConfiguration().setSkipNullEnabled(true); return mapper; } public PropertyMap<BuildingCreator, City> cityMap(){ return new PropertyMap<BuildingCreator, City>() { @Override protected void configure() { map().setId(source.cityId); } }; } public PropertyMap<BuildingCreator, Building> buildingMap(MappingUtils utils) { return new PropertyMap<BuildingCreator, Building>() { @Override protected void configure() { Converter<BuildingCreator, Set<Room>> mapRooms = new AbstractConverter<BuildingCreator, Set<Room>>() { @Override protected Set convert(BuildingCreator buildingCreator) { return new HashSet<>(utils.mapListFromDTO(buildingCreator.getRooms(), Room.class)); } }; using(mapRooms).map(source, destination.getRooms()); } }; } }
This one is similar to BuildingDTO. For modelMapper we need to enable skipNull and add two PropertyMaps. One for mapping building and rooms and second for mapping city. When mapping rooms we need to remember to use proper function from mappingUtils.
And here is corresponding controller:
@PostMapping public BuildingDTO createBuilding(@RequestBody BuildingCreator creator){ Building building = mappingUtils.mapFromDTO(creator, Building.class); building.updateRelations(); return mappingUtils .map(buildingRepository.save(building), BuildingDTO.class); }
We have to adjust relationships manually, otherwise building field from Room will remain null.
And that's all. You can use mappingUtils not only for mapping from entities to DTO, but also from DTO to entity.