Search code examples
javaspringspring-bootdata-conversion

Convert object to a grouped nested object


Given a structure like below, I could not get JPA to map DB results into UserDTO directly, so I am trying to convert UserQueryDTO to UserDTO programmatically.

public class UserQueryDTO {
    private Long id;
    private String username;
    private String record;
    private Day day;
    private Hour hour;
}
public class UserDTO {
    private Long id;
    private String username;
    private List<RecordDayHourDTO> recordDayHours;
}

private class RecordDayHourDTO {
   private String record;
   private List<DayHourDTO> dayHours;
}

private class DayHourDTO {
    private Day day;
    private List<Hour> hours;
}

So, a result like this

[
  {
    "id": 1,
    "username": "john",
    "record": 1,
    "day": 1,
    "hour": 13
  },
  {
    "id": 1,
    "username": "john",
    "record": 1,
    "day": 1,
    "hour": 15
  },
  {
    "id": 1,
    "username": "john",
    "record": 1,
    "day": 5,
    "hour": 9
  },
  {
    "id": 1,
    "username": "john",
    "record": 2,
    "day": 2,
    "hour": 11
  },
  {
    "id": 2,
    "username": "jane",
    "record": 1,
    "day": 1,
    "hour": 23
  },
  {
    "id": 2,
    "username": "jane",
    "record": 2,
    "day": 1,
    "hour": 17
  }
]

should become:

[
  {
    "id": 1,
    "username": "john",
    "recordDayHours": [
      {
        "record": 1,
        "dayHours": [
          { "day": 1, "hours": [13, 15] },
          { "day": 5, "hours": [9] }
        ]
      },
      {
        "record": 2,
        "dayHours": [{ "day": 2, "hours": [11] }]
      }
    ]
  },
  {
    "id": 2,
    "username": "jane",
    "recordDayHours": [
      {
        "record": 1,
        "dayHours": [{ "day": 1, "hours": [23] }]
      },
      {
        "record": 2,
        "dayHours": [{ "day": 1, "hours": [17] }]
      }
    ]
  }
]

Solution

  • There's no chance to map UserQueryDTO to UserDTO in a concise way because there are too many nested objects.

    Attempt to achieve this in a single method will result in unmentionable mess. Hence, I think that the right way to approach this problem would be to extract this logic into a separate class, give the appropriate name to every its piece and document it.

    The approach I come up with is to create make use collector groupingBy() with classifier function which generates an instance of record, holding id and username, as a key and composite collector as the downstream, which would take care of the RecordDayHourDTO. And then iterate over the entries of the map, transforming each entry into UserDTO object.

    The downstream collector in turn comprised of several built-in collectors and a function which internally delegates the job to a couple narrow-focused methods.

    public class QueryToUserMapper {
        private QueryToUserMapper() {}
        
        public static List<UserDTO> toUserDTO(Collection<UserQueryDTO> queries) {
            
            return queries.stream()
                .collect(Collectors.groupingBy(
                    UserDTOKey::new,
                    groupDayHourRecords()
                ))
                .entrySet().stream()
                .map(entry -> new UserDTO(entry.getKey().id(), entry.getKey().username(), entry.getValue()))
                .toList();
        }
        
        public record UserDTOKey(Long id, String username){
            public UserDTOKey(UserQueryDTO query) {
                this(query.getId(), query.getUsername());
            }
        }
        
        public static Collector<UserQueryDTO, ?, List<RecordDayHourDTO>> groupDayHourRecords() {
            
            return Collectors.collectingAndThen(
                Collectors.groupingBy(UserQueryDTO::getRecord,
                    Collectors.groupingBy(UserQueryDTO::getDay,
                        Collectors.mapping(UserQueryDTO::getHour,
                            Collectors.toList()))),
                QueryToUserMapper::toRecordDayHour
            );
        }
        
        public static List<RecordDayHourDTO> toRecordDayHour(Map<String, Map<Day, List<Hour>>> daysMapByRecordId) {
            
            return daysMapByRecordId.entrySet().stream()
                .map(entry -> new RecordDayHourDTO(entry.getKey(), toDayHour(entry.getValue())))
                .toList();
        }
        
        public static List<DayHourDTO> toDayHour(Map<Day, List<Hour>> hoursByDay) {
            
            return hoursByDay.entrySet().stream()
                .map(entry -> new DayHourDTO(entry.getKey(), entry.getValue()))
                .toList();
        }
    }
    

    main()

    public static void main(String[] args) {
        List<UserQueryDTO> queries = List.of(
            new UserQueryDTO(1L, "john", "1", new Day(1), new Hour(13)),
            new UserQueryDTO(1L, "john", "1", new Day(1), new Hour(15)),
            new UserQueryDTO(1L, "john", "1", new Day(5), new Hour(9)),
            new UserQueryDTO(1L, "john", "2", new Day(2), new Hour(11)),
            new UserQueryDTO(2L, "jane", "1", new Day(1), new Hour(23)),
            new UserQueryDTO(2L, "jane", "2", new Day(1), new Hour(17))
        );
        
        List<UserDTO> users = toUserDTO(queries);
        
        users.forEach(System.out::println);
    }
    

    A link to Online Demo - Java 16+

    A link to Online Demo - Java 8