Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Java 8 collect, count with sum

I have a data in the following format:

ProductName | Date
------------|------
ABC         | 1-May
ABC         | 1-May
XYZ         | 1-May
ABC         | 2-May

It is in the form of List, where Product consists of ProductName and Date. Now I wanted to group these data and get the count with sum as follows:

1-May
  -> ABC : 2
  -> XYZ : 1
  -> Total : 3
2-May
  -> ABC: 1
  -> Total : 1

So far what I've achieved is grouping with counting, but not the total value.

myProductList.stream()
  .collect(Collectors.groupingBy(Product::getDate, 
            Collectors.groupingBy(Product::getProductName, Collectors.counting())));

Not sure how do I get the total value.

like image 691
Vimalraj Selvam Avatar asked May 12 '17 16:05

Vimalraj Selvam


1 Answers

You could use Collectors.collectingAndThen to add the entry with the total to every inner map:

Map<LocalDate, Map<String, Long>> result = myProductList.stream()
    .collect(Collectors.groupingBy(
        Product::getDate,
        TreeMap::new, // orders entries by key, i.e. by date
        Collectors.collectingAndThen(
            Collectors.groupingBy(
                Product::getProductName,
                LinkedHashMap::new,     // LinkedHashMap is mutable and 
                Collectors.counting()), // preserves insertion order, i.e.
            map -> {                    // we can insert the total later
                map.put("Total", map.values().stream().mapToLong(c -> c).sum());
                return map;
            })));

The result map contains:

{2017-05-01={ABC=2, XYZ=1, Total=3}, 2017-05-02={ABC=1, Total=1}}

I've specified suppliers for both the outer map and the inner maps. The outer map is a TreeMap, which orders its entries by key (in this case by date). For the inner maps I've decided to go for LinkedHashMap, which is mutable and preserves insertion order, i.e. we will be able to insert the total later, once the inner maps have been filled with data.


So far so good. However, I think we can do it better, since, once each inner map is filled with data, we need to traverse all its values to calculate the total. (This is what map.values().stream().mapToLong(c -> c).sum() actually does). By doing this, we are not taking advantage of the fact that, when counting, every element of the stream adds 1 not only to the group it belongs, but also to the total. Luckily, we can solve this with a custom collector:

public static <T, K> Collector<T, ?, Map<K, Long>> groupsWithTotal(
    Function<? super T, ? extends K> classifier,
    K totalKeyName) {

    class Acc {
        Map<K, Long> map = new LinkedHashMap<>();
        long total = 0L;

        void accumulate(T elem) {
            this.map.merge(classifier.apply(elem), 1L, Long::sum);
            this.total++;
        }

        Acc combine(Acc another) {
            another.map.forEach((k, v) -> {
                this.map.merge(k, v, Long::sum);
                this.total += v;
            });
            return this;
        }

        Map<K, Long> finish() {
            this.map.put(totalKeyName, total);
            return this.map;
        }
    }

    return Collector.of(Acc::new, Acc::accumulate, Acc::combine, Acc::finish);
}

This collector not only counts elements for each group (like Collectors.groupingBy(Product::getProductName, Collectors.counting()) does), but also adds to the total when accumulating and combining. When finishing, it also adds an entry with the total.

To use it, simply invoke the groupsWithTotal helper method:

Map<LocalDate, Map<String, Long>> result = myProductList.stream()
    .collect(Collectors.groupingBy(
        Product::getDate,
        TreeMap::new,
        groupsWithTotal(Product::getProductName, "Total")));

The output is the same:

{2017-05-01={ABC=2, XYZ=1, Total=3}, 2017-05-02={ABC=1, Total=1}}

As a bonus, given LinkedHashMap supports null keys, this custom collector can also group by a null key, i.e. in the rare case that a Product has a null productName, it will create an entry with the null key instead of throwing a NullPointerException.

like image 101
fps Avatar answered Oct 01 '22 00:10

fps