Java stream group by and sum multiple fields
Asked Answered
T

6

22

I have a List fooList

class Foo {
    private String category;
    private int amount;
    private int price;

    ... constructor, getters & setters
}

I would like to group by category and then sum amount aswell as price.

The result will be stored in a map:

Map<Foo, List<Foo>> map = new HashMap<>();

The key is the Foo holding the summarized amount and price, with a list as value for all the objects with the same category.

So far I've tried the following:

Map<String, List<Foo>> map = fooList.stream().collect(groupingBy(Foo::getCategory()));

Now I only need to replace the String key with a Foo object holding the summarized amount and price. Here is where I'm stuck. I can't seem to find any way of doing this.

Thamos answered 28/8, 2018 at 12:44 Comment(5)
have you looked at Collectors.summarizingLong?Motta
Yes but how do you summarize on multiple fields?Thamos
well only recently I've answered something very close to this... https://mcmap.net/q/588070/-efficient-way-to-group-by-a-given-list-based-on-a-key-and-collect-in-same-list-java-8 in jdk-12 seems like there will be a BiCollector that will make your life easierBacciform
Do I understand you correctly, that the KEY shall hold the sums? And that the List<Foo> shall hold all original instances?Unlike
@Unlike yes, that is correct!Thamos
D
26

A bit ugly, but it should work:

list.stream().collect(Collectors.groupingBy(Foo::getCategory))
    .entrySet().stream()
    .collect(Collectors.toMap(x -> {
        int sumAmount = x.getValue().stream().mapToInt(Foo::getAmount).sum();
        int sumPrice= x.getValue().stream().mapToInt(Foo::getPrice).sum();
        return new Foo(x.getKey(), sumAmount, sumPrice);
    }, Map.Entry::getValue));
Deplore answered 28/8, 2018 at 13:21 Comment(1)
I tried to use your approach but I couldn't solve my issue. Here is the link : #74513427Barby
W
9

My variation of Sweeper's answer uses a reducing Collector instead of streaming twice to sum the individual fields:

Map<Foo, List<Foo>> map = fooList.stream()
    .collect(Collectors.groupingBy(Foo::getCategory))
    .entrySet().stream()
    .collect(Collectors.toMap(e -> e.getValue().stream()
        .collect(Collectors.reducing(
            (l, r) -> new Foo(l.getCategory(), l.getAmount() + r.getAmount(), l.getPrice() + r.getPrice())))
         .get(), e -> e.getValue()));

It is not really better though, as it creates a lot of short-lived Foos.

Note however that Foo is required to provide hashCode- and equals-implementations that take only category into account for the resulting map to work correctly. This would probably not be what you want for Foos in general. I would prefer defining a separate FooSummary class to contain the aggregated data.

Wundt answered 28/8, 2018 at 13:45 Comment(0)
T
3

If you have a special, dedicated constructor and hashCode and equals methods consistently implemented in Foo as follows:

public Foo(Foo that) { // not a copy constructor!!!
    this.category = that.category;
    this.amount = 0;
    this.price = 0;
}

public int hashCode() {
    return Objects.hashCode(category);
}

public boolean equals(Object another) {
   if (another == this) return true;
   if (!(another instanceof Foo)) return false;
   Foo that = (Foo) another;
   return Objects.equals(this.category, that.category);
}

The hashCode and equals implementations above allow you to use Foo as a meaningful key in the map (otherwise your map would be broken).

Now, with the help of a new method in Foo that performs the aggregation of the amount and price attributes at the same time, you can do what you want in 2 steps. First the method:

public void aggregate(Foo that) {
    this.amount += that.amount;
    this.price += that.price;
}

Now the final solution:

Map<Foo, List<Foo>> result = fooList.stream().collect(
    Collectors.collectingAndThen(
        Collectors.groupingBy(Foo::new), // works: special ctor, hashCode & equals
        m -> { m.forEach((k, v) -> v.forEach(k::aggregate)); return m; }));

EDIT: a few observations were missing...

On one hand, this solution forces you to use an implementation of hashCode and equals that considers two different Foo instances as equal if they belong to the same category. Maybe this is not desired, or you already have an implementation that takes more or other attributes into account.

On the other hand, using Foo as the key of a map which is used to group instances by one of its attributes is quite an uncommon use case. I think it would be better to just use the category attribute to group by category and have two maps: Map<String, List<Foo>> to keep the groups and Map<String, Foo> to keep the aggregated price and amount, with the key being the category in both cases.

Besides this, this solution mutates the keys of the map after the entries are put into it. This is dangerous, because this could break the map. However, here I'm only mutating attributes of Foo that don't participate in neither hashCode nor equals Foo's implementation. I think that this risk is acceptable in this case, due to the unusuality of the requirement.

Toxoplasmosis answered 28/8, 2018 at 15:29 Comment(0)
M
2

I suggest you create a helper class, which will hold amount and price

final class Pair {
    final int amount;
    final int price;

    Pair(int amount, int price) {
        this.amount = amount;
        this.price = price;
    }
}

And then just collect list to map:

List<Foo> list =//....;

Map<Foo, Pair> categotyPrise = list.stream().collect(Collectors.toMap(foo -> foo,
                    foo -> new Pair(foo.getAmount(), foo.getPrice()),
                    (o, n) -> new Pair(o.amount + n.amount, o.price + n.price)));
Motta answered 28/8, 2018 at 13:5 Comment(1)
that is a good start, but not what the OP needs... read the comments under the questionBacciform
S
0

My take on a solution :)

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

    List<Foo> summarized = new ArrayList<>();
    Map<Foo, List<Foo>> collect = foos.stream().collect(Collectors.groupingBy(new Function<Foo, Foo>() {
        @Override
        public Foo apply(Foo t) {
            Optional<Foo> fOpt = summarized.stream().filter(e -> e.getCategory().equals(t.getCategory())).findFirst();
            Foo f;
            if (!fOpt.isPresent()) {
                f = new Foo(t.getCategory(), 0, 0);
                summarized.add(f);
            } else {
                f = fOpt.get();
            }
            f.setAmount(f.getAmount() + t.getAmount());
            f.setPrice(f.getPrice() + t.getPrice());
            return f;
        }
    }));
    System.out.println(collect);
}
Striation answered 28/8, 2018 at 13:14 Comment(0)
N
0

You can get the sum like this,

ArrayList<Foo> list = new ArrayList<>();
list.add(new Foo("category_1", 10, 20));
list.add(new Foo("category_2", 11, 21));
list.add(new Foo("category_3", 12, 22));
list.add(new Foo("category_1", 13, 23));
list.add(new Foo("category_2", 14, 24));
list.add(new Foo("category_2", 15, 25));

Map<String, Foo> map = list.stream().collect(Collectors.toMap(Foo::getCategory, Function.identity(), (a1, a2) -> {
    a1.joiner(a2);
    return a1;
}));

Make sure to add this method to Foo class as well,

public Foo joiner(Foo that) {
    this.price += that.price;
    this.amount += that.amount;
    return this;
}
Novelize answered 19/8, 2021 at 22:35 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.