Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dynamic grouping by specific attributes with Collection.stream

I am trying to group a list of objects by mulitple attributes, by using Java 8 Collection-Stream.

This works pretty well:

public class MyClass
{
   public String title;
   public String type;
   public String module;
   public MyClass(String title, String type, String module)
   {
      this.type = type;
      this.title = title;
      this.module= module;
   }
}

List<MyClass> data = new ArrayList();
data.add(new MyClass("1","A","B"));
data.add(new MyClass("2","A","B"));
data.add(new MyClass("3","A","C"));
data.add(new MyClass("4","B","A"));

Object result = data.stream().collect(Collectors.groupingBy((MyClass m) 
-> m.type, Collectors.groupingBy((MyClass m) -> m.module)));

But I would like to make it a little more dynamic. I just want to specify an String-Array (or List) which should be used to GroupBy.

Something like:

Object groupListBy(List data, String[] groupByFieldNames)
{
    //magic code
}

and I want to call:

groupListBy(data, new String[]{"type","module"});

How can I make the groupBy-Method more dynamic, like in my example?

like image 366
Thomas Z. Avatar asked Jan 13 '16 15:01

Thomas Z.


People also ask

Which method of collector class can be used to store the result of a Stream in map?

The toMap collector can be used to collect Stream elements into a Map instance. To do this, we need to provide two functions: keyMapper.

How do you collect a Stream from an ArrayList?

All you need to do is first get the stream from List by calling stream() method, then call the filter() method to create a new Stream of filtered values and finally call the Collectors. toCollection(ArrayList::new) to collect those elements into an ArrayList.

How do I get a Stream from collections?

You obtain a stream from a collection by calling the stream() method of the given collection. Here is an example of obtaining a stream from a collection: List<String> items = new ArrayList<String>(); items. add("one"); items.


2 Answers

The main problem with making that code more dynamic is that you don't know in advance how many elements there will be to group by. In such a case, it is best to group by the List of all the elements. This works because two lists are equal if all of their elements are equal and in the same order.

In this case, instead of grouping by the type and then the module, we will group by the list consisting of each data type and module.

private static Map<List<String>, List<MyClass>> groupListBy(List<MyClass> data, String[] groupByFieldNames) {
    final MethodHandles.Lookup lookup = MethodHandles.lookup();
    List<MethodHandle> handles = 
        Arrays.stream(groupByFieldNames)
              .map(field -> {
                  try {
                      return lookup.findGetter(MyClass.class, field, String.class);
                  } catch (Exception e) {
                      throw new RuntimeException(e);
                  }
              }).collect(toList());
    return data.stream().collect(groupingBy(
            d -> handles.stream()
                        .map(handle -> {
                            try {
                                return (String) handle.invokeExact(d);
                            } catch (Throwable e) {
                                throw new RuntimeException(e);
                            }
                        }).collect(toList())
        ));
}

The first part of the code transforms the array of field names into a List of MethodHandle. For each field, a MethodHandle is retrieved for that field: this is done by obtaining a lookup from MethodHandles.lookup() and looking up a handle for the given field name with findGetter:

Produces a method handle giving read access to a non-static field.

The rest of the code creates the classifier to group by from. All the handles are invoked on the data instance to return the list of String value. This Stream is collected into a List to serve as classifier.

Sample code:

public static void main(String[] args) {
    List<MyClass> data = new ArrayList<>();
    data.add(new MyClass("1", "A", "B"));
    data.add(new MyClass("2", "A", "B"));
    data.add(new MyClass("3", "A", "C"));
    data.add(new MyClass("4", "B", "A"));

    System.out.println(groupListBy(data, new String[] { "type", "module" }));
}

Output:

{[B, A]=[4], [A, B]=[1, 2], [A, C]=[3]}

when MyClass.toString() is overriden to return the title only.

like image 158
Tunaki Avatar answered Sep 25 '22 15:09

Tunaki


Instead of list of names, you could also consider supplying a list of functions (with one mandatory) to group your elements.

These functions should map an element of MyClass to an object, so you can use Function<MyClass, ?>.

private static Map<List<Object>, List<MyClass>> groupListBy(List<MyClass> data, Function<MyClass, ?> mandatory, Function<MyClass, ?>... others) {
   return data.stream()
              .collect(groupingBy(cl -> Stream.concat(Stream.of(mandatory), Stream.of(others)).map(f -> f.apply(cl)).collect(toList())));
}

And some example of calls:

groupListBy(data, m -> m.type); //group only by type
groupListBy(data, m -> m.type, m -> m.module); //group by type and by module

Of course you can make this method generic so that it returns a Map<List<Object>, List<U>> with functions of the type U -> Object.

like image 25
Alexis C. Avatar answered Sep 26 '22 15:09

Alexis C.