J-Alex
J-Alex

Reputation: 7107

Java Streams - group by two criteria summing result

I have a list of Orders I should group by two criteria.

Order_Id| Customer |    Date    | Amount |
   1    | "Sam"    | 2019-03-21 | 100    |
   2    | "Nick"   | 2019-03-21 | 102    |
   3    | "Dan"    | 2019-03-21 | 300    |
   4    | "Sam"    | 2019-04-21 | 400    |
   5    | "Jenny"  | 2019-04-21 | 220    |
   6    | "Jenny"  | 2019-04-12 | 330    |

Top buyer for each month by summed amount should be found, for current example:

{
  MARCH: { customer='Dan', amount=300 }, 
  APRIL: { customer='Jenny', amount=550 }
}

There is a solution I was able to find:

public class Main {

    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order(1L, "Sam", LocalDate.of(2019, 3, 21), 100L),
                new Order(2L, "Nick", LocalDate.of(2019, 3, 21), 102L),
                new Order(3L, "Dan", LocalDate.of(2019, 3, 21), 300L),
                new Order(4L, "Sam", LocalDate.of(2019, 4, 21), 400L),
                new Order(5L, "Jenny", LocalDate.of(2019, 4, 21), 220L),
                new Order(6L, "Jenny", LocalDate.of(2019, 4, 12), 330L)
        );

        solution1(orders);
    } 

    private static void solution1(List<Order> orders) {
        final Map<Month, Map<String, Long>> buyersSummed = new HashMap<>();

        for (Order order : orders) {
            Map<String, Long> customerAmountMap = buyersSummed.computeIfAbsent(order.getOrderMonth(), mapping -> new HashMap<>());
            customerAmountMap.putIfAbsent(order.getCustomer(), 0L);
            Long customerAmount = customerAmountMap.get(order.getCustomer());
            customerAmountMap.put(order.getCustomer(), customerAmount + order.getAmount());
        }

        final Map<Month, BuyerDetails> topBuyers = buyersSummed.entrySet().stream()
                .collect(
                        toMap(Entry::getKey, customerAmountEntry -> customerAmountEntry.getValue().entrySet().stream()
                                .map(entry -> new BuyerDetails(entry.getKey(), entry.getValue()))
                                .max(Comparator.comparingLong(BuyerDetails::getAmount)).orElseThrow())
                );

        System.out.println(topBuyers);
    }

}

The data model I used:

class BuyerDetails {
    String customer;
    Long amount;

    public BuyerDetails(String customer, Long amount) {
        this.customer = customer;
        this.amount = amount;
    }

    public String getCustomer() {
        return customer;
    }

    public Long getAmount() {
        return amount;
    }

}

class Order {

    Long id;
    String customer;
    LocalDate orderDate;
    Long amount;

    public Order(Long id, String customer, LocalDate orderDate, Long amount) {
        this.id = id;
        this.customer = customer;
        this.orderDate = orderDate;
        this.amount = amount;
    }

    public Long getId() {
        return id;
    }

    public String getCustomer() {
        return customer;
    }

    public LocalDate getOrderDate() {
        return orderDate;
    }

    public Month getOrderMonth() {
        return getOrderDate().getMonth();
    }

    public Long getAmount() {
        return amount;
    }
}

The question:

Is there any way to solve the task above in one stream?

Upvotes: 8

Views: 1252

Answers (6)

Anton Balaniuc
Anton Balaniuc

Reputation: 11739

It has a nested stream, so it is not one stream and it returns Map<String, Optional<BuyerDetails>>.


orders.stream()
        .collect(
            Collectors.groupingBy(Order::getOrderMonth,
                Collectors.collectingAndThen(
                        Collectors.groupingBy(
                                Order::getCustomer,
                                Collectors.summarizingLong(Order::getAmount)
                        ),
                        e -> e.entrySet()
                                .stream()
                                .map(entry -> new BuyerDetails(entry.getKey(), entry.getValue().getSum()))
                                .max(Comparator.comparingLong(BuyerDetails::getAmount))
                )
            )
        )

so there 3 steps:

  • Group by month Collectors.groupingBy(Order::getOrderMonth,
  • Group by customer name and summing total order amount Collectors.groupingBy(Order::getCustomer, Collectors.summarizingLong( Order::getAmount))
  • filtering intermediate result and leaving only customers with maximum amount max(Comparator.comparingLong(BuyerDetails::getAmount))

output is

{
  APRIL = Optional [ BuyerDetails { customer = 'Jenny', amount = 550 } ],
  MARCH = Optional [ BuyerDetails { customer = 'Dan', amount = 300 } ]
}

I am curious as well if this can be done without additional stream.

Upvotes: 2

Nidhish Krishnan
Nidhish Krishnan

Reputation: 20741

Try using groupingBy, summingLong and comparingLong like as shown below

Map<Month, BuyerDetails> topBuyers = orders.stream()
    .collect(Collectors.groupingBy(Order::getOrderMonth,
             Collectors.groupingBy(Order::getCustomer,
             Collectors.summingLong(Order::getAmount))))
    .entrySet().stream()
    .collect(Collectors.toMap(Map.Entry::getKey,
             order -> order.getValue().entrySet().stream()
            .max(Comparator.comparingLong(Map.Entry::getValue))
            .map(cust -> new BuyerDetails(cust.getKey(), cust.getValue())).get()));

Output

{
  "MARCH": { "customer": "Dan", "amount": 300 }, 
  "APRIL": { "customer": "Jenny", "amount": 550 }
}

Upvotes: 4

Avi
Avi

Reputation: 2641

Alright, here we go! The following code will get you what you want, with exactly 1 call to stream():

Map<Month, BuyerDetails> grouped = orders.stream().collect(
  Collectors.groupingBy(Order::getOrderMonth,
    Collectors.collectingAndThen(
      Collectors.groupingBy(Order::getCustomer,
        Collectors.summingLong(Order::getAmount)
      ),
      ((Function<Map<String,Long>, Map.Entry<String,Long>>) 
        map -> Collections.max(
          map.entrySet(), Comparator.comparingLong(Map.Entry::getValue)
        )
      ).andThen(
        e -> new BuyerDetails(e.getKey(),e.getValue())
      )
    )
  )
);
System.out.println(grouped);

Output:

{MARCH={ customer='Dan', amount=300 }, APRIL={ customer='Jenny', amount=550 }}

Now, this is a bit of a doozy, so let's go through it line by line to see what's happening:

Map<Month, BuyerDetails> grouped = orders.stream().collect(

First, we stream the orders we have,

  Collectors.groupingBy(Order::getOrderMonth,

grouping by the month, we find:

    Collectors.collectingAndThen(
      Collectors.groupingBy(Order::getCustomer,

each Customer and

        Collectors.summingLong(Order::getAmount)
      ),

their total orders within the month.

      ((Function<Map<String,Long>, Map.Entry<String,Long>>)

(We cast to Function, so we can use methods like andThen on the lambda function we define)

        map -> Collections.max(
          map.entrySet(), Comparator.comparingLong(Map.Entry::getValue)
        )

For each month, we find the Customer with maximal order sum.

      ).andThen(

Then, we

        e -> new BuyerDetails(e.getKey(),e.getValue())

create a new buyer detail for said customer

      )
    )
  )
);

and collect all Month/BuyerDetail pairs.

System.out.println(grouped);

Finally, we print the created data structure.

Upvotes: 2

Nikolai  Shevchenko
Nikolai Shevchenko

Reputation: 7521

This can't be done with single stream since both sum and max are terminal operations and they can't be applied to same stream. Better split this into two operations

Map<Month, Map<String, Long>> sumsByMonth = orders.stream().collect(
        Collectors.groupingBy(
            Order::getOrderMonth,
            Collectors.groupingBy(
                    Order::getCustomer,
                    Collectors.mapping(
                            Order::getAmount,
                            Collectors.reducing(0L, a -> a, (a1, a2) -> a1 + a2)
                    )
            )
        )
);

Map<Month, BuyerDetails> topBuyers = sumsByMonth.entrySet().stream().collect(
        Collectors.toMap(
                Map.Entry::getKey,
                sums -> sums.getValue().entrySet().stream()
                        .max(Comparator.comparingLong(Map.Entry::getValue))
                        .map(e -> new BuyerDetails(e.getKey(), e.getValue()))
                        .get()
       )
);

Upvotes: 0

John Bollinger
John Bollinger

Reputation: 180103

Is there any way to solve the task above in one stream?

It depends on what you mean by "in one stream". You want to perform a reduction operation that is probably best characterized as a composite of a sequence of reductions:

  • group the orders by month
  • within each monthly group, aggregate the orders for each customer to yield a total amount
  • among each monthly group of per-customer aggregate results, choose the one with the greatest amount (note: not well defined in the case of ties)

From the perspective of the Stream API, performing any one of those individual reductions on a stream is a terminal operation on that stream. You can process the result with a new stream, even chaining that together syntactically, but although that might take the syntactic form of a single chain of method invocations, it would not constitute all operations happening on a single stream.

You could also create a single Collector (or the components of one) so that you get the result directly by collecting the stream of your input elements, but internally, that collector would still need to perform the individual reductions, either by internally creating and consuming additional streams, or by performing the same tasks via non-stream APIs. If you count those internal operations then again, no, it would not constitute performing operations on a single stream. (But if you don't consider those internal reductions, then yes, this does it all on one stream.)

Upvotes: 4

Vitaly
Vitaly

Reputation: 115

My approach (3 streams):

private static void solution1(List<Order> orders) {
        final Map<Month, BuyerDetails> topBuyers = orders.stream().collect(
                Collectors.groupingBy(order -> order.getCustomer() + "$" + order.getOrderDate().getYear() + "." +
                                order.getOrderMonth(),
                        Collectors.reducing((ord1, ord2) -> {
                            ord1.setAmount(ord1.getAmount() + ord2.getAmount());
                            return ord1;
                        }))).values().stream()
                .collect(Collectors.groupingBy(order -> order.get().getOrderMonth(),
                        maxBy(Comparator.comparing(order -> order.get().getAmount())))).values().stream()
                .collect(
                        toMap((key) -> key.get().get().getOrderMonth(),
                                key -> new BuyerDetails(key.get().get().getCustomer(), key.get().get().getAmount())
                        )
                );
    }

Upvotes: 1

Related Questions