3

enter image description here

I want to build a JPQL query to map data of that structure to this DTO:

@AllArgsConstructor
class UserDTO {
  long userId;
  long countOfContacts;
  Map<String,Long> countOfActions; // by type
}

I don't know how to extract the counts per action-type in JPQL, this is where I am stuck (see my name? :) ) :

public interface UserRepository extends CrudRepository<User, Long> {
    @Query("SELECT new example.UserDTO( "
            + "   u.id, "
            + "   COUNT(contacts), "
        --> + "   ???group by actions.type to map<type,count>??? " <---
            + " ) "
            + " FROM User u "
            + " LEFT JOIN u.actions actions "
            + " LEFT JOIN u.contacts contacts "
            + " GROUP BY u.id")
    List<UserDTO> getAll();
}

I use postgres and would be fine with a native query as well if this is not possible in JPQL.

Actually, I can solve it with a native query and mapping the actions data in Java, but it feels bad:

SELECT
  u.id,
  COALESCE(MIN(countOfContacts.count), 0) as countOfContacts,
  ARRAY_TO_STRING(ARRAY_REMOVE(ARRAY_AGG(actions.type || ':' || actions.count), null),',') AS countOfActions
FROM user u
LEFT JOIN (
    SELECT
      user_id, COUNT(*) as count
    FROM contact
    GROUP BY user_id
) countOfContacts
  ON countOfContacts.user_id = u.id
LEFT JOIN (
    SELECT
      user_id, type, COUNT(*)
    FROM action
    GROUP BY user_id, type
) actions
  ON actions.user_id = u.id
GROUP BY u.id
;

Resulting in such result data:

  id   | countOfContacts |     countOfActions                            
--------+-----------------+-------------------------
 11728 |               0 | {RESTART:2}
  9550 |               0 | {}
  9520 |               0 | {CLEAR:1}
 12513 |               0 | {RESTART:2}
 10238 |               3 | {CLEAR:2,RESTART:5}
 16531 |               0 | {CLEAR:1,RESTART:7}
  9542 |               0 | {}
...

Since in a native query we cannot map to the POJO, I return List<String[]> and convert all columns by myself to the UserDTO's constructor:

@Query(/*...*/)
/** use getAllAsDTO for a typed result set */
List<String[]> getAll();

default List<UserDTO> getAllAsDTO() {
  List<String[]> result = getAll();
  List<UserDTO> transformed = new ArrayList<>(result.size());
  for (String[] row : result) {
    long userId = Long.parseLong(row[0]);
    long countOfContacts = Long.parseLong(row[1]);
    String countOfActions = row[2];
    transformed.add(
      new UserDTO(userId, countOfContacts, countOfActions)
    );
  }
  return transformed;
}

Then I map the countOfActions to Java Map<String, Long> in the constructor of the DTO:

    class UserDTO {
        long userId;
        long countOfContacts;
        Map<String,Long> countOfActions; // by type

        /**
         * @param user
         * @param countOfContacts
         * @param countOfActions {A:1,B:4,C:2,..} will not include keys for 0
         */
        public UserDTO(long userId, long countOfContacts, String countOfActionsStr) {
            this.userId = userId;
            this.countOfContacts = countOfContacts;
            this.countOfActions = new HashMap<>();
            // remove curly braces
            String s = countOfActionsStr.replaceAll("^\\{|\\}$", "");
            if (s.length() > 0) { // exclude empty "arrays"
              for (String item : s.split(",")) {
                  String[] tmp = item.split(":");
                  String action = tmp[0];
                  long count = Long.parseLong(tmp[1]);
                  countOfActions.put(action, count);
              }
            }
        }
    }

Can I solve it on the DB layer already?

Andronicus
  • 25,419
  • 17
  • 47
  • 88
Stuck
  • 11,225
  • 11
  • 59
  • 104

1 Answers1

3

Unfortunately JPQL doesn't have an aggregation function like string_agg or group_concat. So you should transform the query result yourself. First, you should create a "plain" query like this, for example:

@Query("select new example.UserPlainDto( " + 
       "  a.user.id, " +
       "  count(distinct c.id), " +
       "  a.type, " +
       "  count(distinct a.id) " +
       ") " +
       "from " +
       "  Action a " +
       "  join Contact c on c.user.id = a.user.id " +
       "group by " +
       "  a.user.id, a.type")
List<UserPlainDto> getUserPlainDtos();

(it's HQL - Hibernate expansion of JPQL)

The result of this query will be a plain table, for example:

|--------|---------------|-------------|-------------|
|user_id |countact_count |action_type  |action_count |
|--------|---------------|-------------|-------------|
|1       |3              |ONE          |1            |
|1       |3              |TWO          |2            |
|1       |3              |THREE        |3            |
|2       |2              |ONE          |1            |
|2       |2              |TWO          |2            |
|3       |1              |ONE          |1            |
|--------|---------------|-------------|-------------|

Then you should group that result to collection of UserDto, something like this:

default Collection<UserDto> getReport() {
    Map<Long, UserDto> result = new HashMap<>();

    getUserPlainDtos().forEach(dto -> {
        long userId = dto.getUserId();
        long actionCount = dto.getActionCount();

        UserDto userDto = result.getOrDefault(userId, new UserDto());
        userDto.setUserId(userId);
        userDto.setContactCount(dto.getContactCount());
        userDto.getActions().compute(dto.getActionType(), (type, count) -> count != null ? count + actionCount : actionCount);
        result.put(userId, userDto);
    });

    return result.values();
}

Then voila, you will get such a result in Collection<UserDto>:

[
    {
        "userId": 1,
        "contactCount": 3,
        "actions": {
            "ONE": 1,
            "TWO": 2,
            "THREE": 3
        }
    },
    {
        "userId": 2,
        "contactCount": 2,
        "actions": {
            "ONE": 1,
            "TWO": 2
        }
    },
    {
        "userId": 3,
        "contactCount": 1,
        "actions": {
            "ONE": 1
        }
    }
]

DTOs are used above:

@Value
class UserPlainDto {
    long userId;
    long contactCount;
    ActionType actionType;
    long actionCount;
}

@Data
class UserDto {
    long userId;
    long contactCount;
    Map<ActionType, Long> actions = new HashMap<>();
}

My demo project.

Stuck
  • 11,225
  • 11
  • 59
  • 104
Cepr0
  • 28,144
  • 8
  • 75
  • 101
  • 1
    While the question is about leveraging the DB layer, I understand from this answer that we probably won't be able to do it. The provided solution is at least very clean in terms of easy to grasp and type safety. Thanks – Stuck Jan 22 '20 at 16:36
  • Unfortunately this won't work when paging because the page size will be applied to the rows with the duplicated top level object, before post-processing. – Sebastiaan van den Broek Apr 28 '21 at 06:42
  • For pagination another query should first select the page data and then the given solution can be limited to the relevant users. If pagination depends on the counts, a materialized view can help. – Stuck Jun 18 '21 at 06:10