MatMat
MatMat

Reputation: 908

Java stream group by and sum multiple fields

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.

Upvotes: 22

Views: 43500

Answers (6)

Hulk
Hulk

Reputation: 6573

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.

Upvotes: 9

ray
ray

Reputation: 1670

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;
}

Upvotes: 0

fps
fps

Reputation: 34450

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.

Upvotes: 3

Sweeper
Sweeper

Reputation: 270960

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));

Upvotes: 26

Dakshinamurthy Karra
Dakshinamurthy Karra

Reputation: 5463

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);
}

Upvotes: 0

dehasi
dehasi

Reputation: 2773

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)));

Upvotes: 2

Related Questions