18

I want to group a list of person's. A person have some attributes like name, country, town, zipcode, etc. I wrote the static code, which works very well:

Object groupedData = data.stream().collect(groupingBy(Person::getName, Collectors.groupingBy(Person::getCountry, Collectors.groupingBy(Person::getTown))));

But the problem is, that is it not dynamic. Sometimes I want to just group by name and town, sometimes by a attributes. How can I do this? Non Java 8 solutions are welcome as well.

Tunaki
  • 132,869
  • 46
  • 340
  • 423
Thomas Z.
  • 566
  • 6
  • 21

2 Answers2

11

You could create a function taking an arbitrary number of attributes to group by and construct the groupingBy-Collector in a loop, each time passing the previous version of itself as the downstream collector.

public static <T> Map collectMany(List<T> data, Function<T, ?>... groupers) {
    Iterator<Function<T, ?>> iter = Arrays.asList(groupers).iterator();
    Collector collector = Collectors.groupingBy(iter.next());
    while (iter.hasNext()) {
        collector = Collectors.groupingBy(iter.next(), collector);
    }
    return (Map) data.stream().collect(collector);
}

Note that the order of the grouper functions is reversed, so you have to pass them in reversed order (or reverse them inside the function, e.g. using Collections.reverse or Guava's Lists.reverse, whichever you prefer).

Object groupedData = collectMany(data, Person::getTown, Person::getCountry, Person::getName);

Or like this, using an old-school for loop to reverse the array in the function, i.e. you don't have to pass the groupers in inverse order (but IMHO this is harder to comprehend):

public static <T> Map collectMany(List<T> data, Function<T, ?>... groupers) {
    Collector collector = Collectors.groupingBy(groupers[groupers.length-1]);
    for (int i = groupers.length - 2; i >= 0; i--) {
        collector = Collectors.groupingBy(groupers[i], collector);
    }
    return (Map) data.stream().collect(collector);
}

Both approaches will return a Map<?,Map<?,Map<?,T>>>, just as in your original code. Depending on what you want to do with that data it might also be worth considering using a Map<List<?>,T>, as suggested by Tunaki.

Community
  • 1
  • 1
tobias_k
  • 81,265
  • 12
  • 120
  • 179
8

You can group by a list formed of the attributes you want to group by.

Imagine you want to group by the name and the country. Then you could use:

Map<List<Object>, List<Person>> groupedData = 
    data.stream().collect(groupingBy(p -> Arrays.asList(p.getName(), p.getCountry())));

This works because two lists are considered equal when they contain the same element in the same order. Therefore, in the resulting map, you will have a key for each different name / country pair and as value the list of persons with those specific name and country. Put another way, instead of saying "group by name, then group by country", it effectively says "group by name and country". The advantage is that you don't end-up with a map of maps of maps, etc.

Tunaki
  • 132,869
  • 46
  • 340
  • 423
  • "The advantage is that you don't end-up with a map of maps of maps" assuming that that's not what OP actually _wants_ to end up with... – tobias_k Aug 01 '16 at 12:41
  • 2
    Well that requirement wasn't part of the question @tobias_k. A map with only key, value is easier to work with that a map with unknown levels of inner maps. – Tunaki Aug 01 '16 at 14:24
  • It was part of the question in the sense that it's what OP's code currently does, "which works very well", according to OP. Anyway, I think which approach to follow very much depends on how you want to use the data, and there are certainly scenarios where your approach is easier to handle. – tobias_k Aug 01 '16 at 14:42