'Java stream: How to map to a single item when using groupingBy instead of to list

Given an order class like:

@ToString
@AllArgsConstructor
@Getter
static class Order {
    long customerId;
    LocalDate orderDate;
}

and a list of orders:

List<Order> orderList = List.of(new Order(1, LocalDate.of(2020,Month.APRIL,21)),
                                new Order(1, LocalDate.of(2021,Month.APRIL,21)),
                                new Order(1, LocalDate.of(2022,Month.APRIL,21)),
                                new Order(2, LocalDate.of(2020,Month.APRIL,21)),
                                new Order(2, LocalDate.of(2021,Month.APRIL,21)),
                                new Order(3, LocalDate.of(2020,Month.APRIL,21)),
                                new Order(3, LocalDate.of(2022,Month.APRIL,21)),
                                new Order(4, LocalDate.of(2020,Month.APRIL,21)));

I need to get a list of customerId where last orderDate is older than 6 months. So for above example [2,4]. My idea is to first to group by customerId, second map to last orderDate and third to filter those which are older than 6 months. I am stuck at second step on how to map to a single order with the recent orderDate

First step

Map<Long, List<Order>> grouped =
        orderList.stream()
                .collect(Collectors.groupingBy(Order::getCustomerId));

Second step (stuck here how to change the above to get only one item as value)

Map<Long, Order> grouped =
        orderList.stream()
                .collect(Collectors.groupingBy(Order::getCustomerId, ???));

or even better

Map<Long, LocalDate> grouped =
        orderList.stream()
                .collect(Collectors.groupingBy(Order::getCustomerId, ???));

I have tried to use Collectors.mapping() , Collectors.reducing() and Collectors.maxBy() but having a lot of compile errors.



Solution 1:[1]

Use Collectors.toMap collector to have a map of listed orders by customer id. After that, you can filter only those orders which are older than 6 months.

See the implementation below:

import java.time.LocalDate;
import java.time.chrono.ChronoLocalDate;
import java.util.List;
import java.util.Optional;
import java.util.Collections;
import java.util.Objects;
import java.util.Comparator;
import java.util.function.BinaryOperator;
import java.util.function.Function;
import java.util.stream.Collectors;
public static List<Long> getCustomerIdsOfOrdersOlderThanSixMonths(final List<Order> orderList) {
    return Optional.ofNullable(orderList)
            .orElse(Collections.emptyList())
            .stream()
            .filter(o -> Objects.nonNull(o) && Objects.nonNull(o.getOrderDate()))
            .collect(Collectors.toMap(
                  Order::getCustomerId,
                  Function.identity(),
                  BinaryOperator.maxBy(Comparator.comparing(Order::getOrderDate))))
            .values()
            .stream()
            .filter(o -> o.getOrderDate()
                  .plusMonths(6)
                  .isBefore(ChronoLocalDate.from(LocalDate.now())))
            .map(Order::getCustomerId)
            .collect(Collectors.toList());
    }
}
List<Long> customerIds = getCustomerIdsOfOrdersOlderThanSixMonths(orderList);
// [2, 4]

Solution 2:[2]

You can use groupingBy() with a downstream maxBy() collector, and then filter the results to just those dates that are older than six months:

import java.time.LocalDate;
import java.time.Month;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

public class Demo {
    private record Order(long customerId, LocalDate orderDate) {}

    public static void main(String[] args) {
        List<Order> orderList =
            List.of(new Order(1, LocalDate.of(2020,Month.APRIL,21)),
                    new Order(1, LocalDate.of(2021,Month.APRIL,21)),
                    new Order(1, LocalDate.of(2022,Month.APRIL,21)),
                    new Order(2, LocalDate.of(2020,Month.APRIL,21)),
                    new Order(2, LocalDate.of(2021,Month.APRIL,21)),
                    new Order(3, LocalDate.of(2020,Month.APRIL,21)),
                    new Order(3, LocalDate.of(2022,Month.APRIL,21)),
                    new Order(4, LocalDate.of(2020,Month.APRIL,21)));

        final LocalDate sixMonthsAgo = LocalDate.now().minusMonths(6);

        List<Order> mostRecentOrders =
            orderList.stream()
            .collect(Collectors.groupingBy(Order::customerId,
                                           Collectors.maxBy(Comparator.comparing(Order::orderDate))))
            .values().stream()
            .filter(opt -> opt.filter(o -> o.orderDate().isBefore(sixMonthsAgo)).isPresent())
            .map(Optional::orElseThrow)
            .collect(Collectors.toList());

        System.out.println(oldOrders);
    }
}

outputs

[Order[customerId=2, orderDate=2021-04-21], Order[customerId=4, orderDate=2020-04-21]]

First you get a map of customer ids and (Wrapped in an Optional due to the way Collectors.maxBy() works) the most recent order by date. Then filter out the entries where that most recent date is within the last six months. Then extract the remaining orders from the Optionals and return them in a List. If you just want the customer IDs and don't care about the rest of the Order object, modify the final map() and returned type appropriately.

Solution 3:[3]

Here's another approach on doing this. Use Collectors.partioningBy to segregate the dates. Set up a couple variables to help keep things straight in the partitioning and printing process.

static boolean WITHIN_LAST_SIX_MONTHS = false;
static boolean BEFORE_SIX_MONTHS_AGO = true;
  • partition the Order based on if it was issued before 6 months ago (true) or within the last 6 months (false).
  • then map the Order to just the CustomerId and return as a Set (no need for duplicates)
  • And that's it.
Map<Boolean, Set<Long>> result = orderList.stream()
                .collect(Collectors.partitioningBy(
                        order -> order.getOrderDate()
                                .isBefore(LocalDate.now()
                                        .minusMonths(6)),
                        Collectors.mapping(
                                Order::getCustomerId,
                                Collectors.toSet())));

System.out.println("BEFORE_SIX_MONTHS_AGO="+result.get(BEFORE_SIX_MONTHS_AGO));
System.out.println("WITHIN_LAST_SIX_MONTHS="+result.get(WITHIN_LAST_SIX_MONTHS));
        

prints

BEFORE_SIX_MONTHS_AGO=[1, 2, 3, 4]
WITHIN_LAST_SIX_MONTHS=[1, 3]

Now just remove those ID's of recent orders from those that occurred over six months ago.

result.get(BEFORE_SIX_MONTHS_AGO).removeAll(result.get(WITHIN_LAST_SIX_MONTHS));
System.out.println(result.get(BEFORE_SIX_MONTHS_AGO));

prints

[2, 4]

Note that the final set could be returned directly from the stream by using collectingAndThen for the partitioning collector. The finisher would look like this.

thismap -> {
    Set<Long> retSet = new HashSet<>(thismap.get(BEFORE_SIX_MONTHS_AGO));
    retSet.removeAll(thismap.get(WITHIN_LAST_SIX_MONTHS));
    return retSet;
}

But imo, this is rather busy and actually complicates matters.

Solution 4:[4]

You can use Collectors.toMap with a mergeFunction for your step 2:

 Map<Long, LocalDate> latestOrderByCustomer = 
            orderList.stream()
                     .collect(Collectors.toMap(Order::customerId, 
                                                Order::orderDate, 
                                                (order1, order2) -> order1.isAfter(order2) ? order1 : order2));

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2
Solution 3 WJS
Solution 4 user3588254