Collectors mapping in Java

Introduction

Collectors mapping in Java is a static method among the many methods available in the Collectors class in Java. In this post, we will learn about the mapping method in Collectors.

Collectors mapping in Java

We use the Collectors mapping in Java to adapt a Collector that accepts elements of type U to one accepting elements of type T by applying a mapping function to each element in the stream before accumulation. We use it mostly in a multi-level reduction such as the downstream of groupingBy or partitioningBy.

Method signature of Collectors mapping

The method signature of Collectors mapping is:

public static <T, U, A, R>
    Collector<T, ?, R> mapping(Function<? super T, ? extends U> mapper,
                               Collector<? super U, A, R> downstream)

The mapper is a function which is applied to all the input elements before passing them to the downstream collector (it thus accepts the mapped values). Thus, calling Collectors#mapping will return a new collector which will apply the mapping function to each element in the stream and provides the mapped values to the downstream collector.

Collectors#mapping example setup

To demonstrate the mapping function in the Collectors class, let us use a Order model. An order has an orderId, the customerId, the discount and the list of OrderItems in the order. An OrderItem has the item’s id, name, price and quantity.

public class Order {
    private int orderId;
    private int customerId;
    private double discountAmount;
    private List<OrderItem> items;

    private Order(Builder builder) {
        this.orderId = builder.orderId;
        this.customerId = builder.customerId;
        this.discountAmount = builder.discountAmount;
        this.items = builder.items;
    }

    public int getOrderId() {
        return orderId;
    }

    public int getCustomerId() {
        return customerId;
    }

    public double getDiscountAmount() {
        return discountAmount;
    }

    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);
    }

    public double getTotal() {
        return this.items.stream()
                .mapToDouble(orderItem -> orderItem.getPrice() * orderItem.getQuantity())
                .sum();
    }

    public static Builder builder(int orderId) {
        return new Builder(orderId);
    }

    public static class Builder {
        private int orderId;
        private int customerId;
        private double discountAmount;
        private List<OrderItem> items = new ArrayList<>();

        public Builder(int orderId) {
            this.orderId = orderId;
        }

        public Builder withCustomerId(int customerId) {
            this.customerId = customerId;
            return this;
        }

        public Builder withDiscountAmount(double discountAmount) {
            this.discountAmount = discountAmount;
            return this;
        }

        public Builder withItems(List<OrderItem> items) {
            this.items = new ArrayList<>(items);
            return this;
        }

        public Order build() {
            return new Order(this);
        }
    }
}

I have used the Builder pattern to build an Order. The Order class is made immutable by copying the passed list of OrderItems before assigning. Similarly, the getItems() returns an unmodifiable list. Check out the posts related to immutability if interested:

The Order class has getTotal method which computes the total price of the order (without discount) and returns it. The mapToDouble returns a DoubleStream on which we call the sum.
The OrderItem class is:

public class OrderItem {
    private int itemId;
    private String itemName;
    private double price;
    private int quantity;

    public OrderItem(int itemId,  String itemName, double price, int quantity) {
        this.itemId = itemId;
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }

    public int getItemId() {
        return itemId;
    }

    public String getItemName() {
        return itemName;
    }

    public double getPrice() {
        return price;
    }

    public int getQuantity() {
        return quantity;
    }
    
    @Override
    public String toString() {
        return itemName;
    }
}

Let us create a list of orders which we will use for the rest of this post.

List<Order> orders = List.of(
        Order.builder(1)
                .withCustomerId(10)
                .withDiscountAmount(5.0)
                .withItems(List.of(
                        new OrderItem(1, "Item-1", 15.0, 1),
                        new OrderItem(2, "Item-2", 10.0, 2)))
                .build(),
        Order.builder(2)
                .withCustomerId(10)
                .withDiscountAmount(0.0)
                .withItems(List.of(
                        new OrderItem(1, "Item-1", 15.0, 2),
                        new OrderItem(3, "Item-3", 17.5, 1)))
                .build(),
        Order.builder(3)
                .withCustomerId(11)
                .withDiscountAmount(5.0)
                .withItems(List.of(
                        new OrderItem(4, "Item-4", 25.5, 2),
                        new OrderItem(5, "Item-5", 5.5, 1)))
                .build()
);

We have three orders:

  • Order 1 belongs to customer id 10. It has two items (item 1 and 2).
  • Order 2 also belongs to customer id 10. It has two items (item 1 and 3).
  • Similarly Order 3 belongs to customer id 11 which has two items (item 4 and 5).

Collectors.mapping with Collectors.toList

Let us group the order by their id mapping to the customer to which the order belongs to. Since a customer can have multiple orders, the value would be a list of order ids.

Map<Integer, List<Integer>> customerIdToOrders = orders.stream()
        .collect(Collectors.groupingBy(Order::getCustomerId,
                Collectors.mapping(Order::getOrderId, Collectors.toList())));
System.out.println(customerIdToOrders);
For this, we will use Collectors.groupingBy to group by the customer id. We pass Collectors.mapping as the downstream collector of groupingBy. The Collectors.toList is the downstream collector of mappingBy.
 
The result is:
{10=[1, 2], 11=[3]}

The customer with id 10 has orders 1 and 2 whereas the customer id 11 has order id 3.

Collectors.mapping with Collectors.counting

Let us find the number of orders for each customer.

Map<Integer, Long> numberOfOrdersPerCustomer = orders.stream()
        .collect(Collectors.groupingBy(Order::getCustomerId,
                Collectors.mapping(Function.identity(), Collectors.counting())));
System.out.println(numberOfOrdersPerCustomer);

We used Collectors.groupingBy again to group by the customer id. The mapping function is Function.identity which means we return the same Order object. But this time, we used Collectors.counting as the downstream collector of mapping to find the number of orders a customer has. The result is:

{10=2, 11=1}

Collectors.mapping with Collectors.summingDouble

Now we will find the total price among all orders for each customer. In other words, we will compute the sum of all order prices (total) by customer (without applying discount).

Map<Integer, Double> customerToTotal = orders.stream()
        .collect(Collectors.groupingBy(Order::getCustomerId,
                Collectors.mapping(Order::getTotal, Collectors.summingDouble(total -> total))));
System.out.println(customerToTotal);

Again groupingBy the customer id, we call the getTotal method of each order thus mapping the Order instance to its total. The Collectors.summingDouble is the downstream collector which sums the total of all orders belonging to a customer.

{10=82.5, 11=56.5}

The output says the total of customer id 10 is 82.5 and customer id 11 is 56.5.

Collectors.mapping with Collectors.maxBy

Let us find what is the most expensive item purchased by each customer (without considering the quantity). We will group by the customer id as did earlier. We want to map each Order to extract the most expensive item in it. Here’s a helper method for doing that.

private OrderItem getExpensiveItemForOrder(Order order) {
    return order.getItems().stream()
            .max(Comparator.comparingDouble(OrderItem::getPrice))
            .get(); //assume getItems() won't be empty
}

This streams the items in an Order and gets the item which has the highest price. It does this by using Comparator.comparingDouble method. Check out the Comparator comparing post to learn about the Comparator methods.

Note: Since max on the Stream returns an Optional, this method assumes that the order list won’t be empty.

Map<Integer, Optional<OrderItem>> expensiveOrderItemPerCustomer = orders.stream()
        .collect(Collectors.groupingBy(Order::getCustomerId,
                Collectors.mapping(o -> getExpensiveItemForOrder(o),
                        Collectors.maxBy(Comparator.comparingDouble(OrderItem::getPrice)))));
System.out.println(expensiveOrderItemPerCustomer);

In the above code, we use the helper method we wrote to map each Order to an OrderItem which is the most expensive in that Order. Next, we use Collectors.maxBy as the downstream collector. We pass a Comparator (comparingDouble) to compare the price of different OrderItems and to get the maximum OrderItem i.e., after we get the expensive item from each order, we get the most expensive item among them. This gives us an Optional<OrderItem>. The output is:

{10=Optional[Item-3], 11=Optional[Item-4]}

Customer id 10’s expensive item is Item-3 and for customer id 11, it is Item-4.

Note: When using a method reference  we can write the lambda expression o -> getExpensiveItemForOrder(o) as this::getExpensiveItemForOrder or <TheClassName>:: getExpensiveItemForOrder if the code is in a static method.

When an order can have empty items

In the above section, we assumed the orders list won’t be empty. If it can be empty, then calling get() on an Optional will throw an exception.

To fix this, let us return an Optional from the getExpensiveItemForOrder method.

private Optional<OrderItem> getExpensiveItemForOrder(Order order) {
    return order.getItems().stream()
            .max(Comparator.comparingDouble(OrderItem::getPrice));
}

Now, when we map an order using getExpensiveItemForOrder method we get back an Optional<OrderItem>. We can use Collectors.filtering to filter the Optionals that are not empty. Next, we again use a mapping collector to extract the value from the Optional. Finally, we use a maxBy collector to get the max element according to the passed comparator (this part is same as before).

Map<Integer, Optional<OrderItem>> expensiveOrderItemPerCustomer = orders.stream()
        .collect(Collectors.groupingBy(Order::getCustomerId,
                Collectors.mapping(this::getExpensiveItemForOrder,
                        Collectors.filtering(Optional::isPresent,
                                Collectors.mapping(Optional::get,
                                        Collectors.maxBy(Comparator.comparingDouble(OrderItem::getPrice)))))));
System.out.println(expensiveOrderItemPerCustomer);

Using Collectors.mapping with Collectors.partitioningBy

Let us now use Collectors.partitioningBy to partition the orders into two sets – those having discounts and those that don’t have any discount. For each partition, let us list the order ids in it.

Map<Boolean, List<Integer>> orderIdsByDiscount = orders.stream()
        .collect(Collectors.partitioningBy(order -> order.getDiscountAmount() > 0,
                Collectors.mapping(Order::getOrderId, Collectors.toList())));
System.out.println(orderIdsByDiscount);

Prints,

{false=[2], true=[1, 3]}

Order ids 1 and 3 have discount whereas order id 2 has no discounts.

As an another example, let us partition by presence of discount. For each partition, let us group by the customer id and for each customer id find the sum of total of all orders.

Map<Boolean, Map<Integer, Double>> customerToTotalByDiscount = orders.stream()
        .collect(Collectors.partitioningBy(order -> order.getDiscountAmount() > 0,
                Collectors.groupingBy(Order::getCustomerId,
                        Collectors.mapping(Order::getTotal, Collectors.summingDouble(total -> total)))));
System.out.println(customerToTotalByDiscount);

Prints,

{false={10=47.5}, true={10=35.0, 11=56.5}}

Since order id 2 has no discount, it is in the false category. In that the order 2 is owned by customer id 10 with the order total of 47.5.

Orders 1 and 3 has discounts and hence they are in the true category. Order id 1 has a total of 35 and order id 3 has total of 56.5 and they belong to customer id 10 and 11 respectively.

Conclusion

In this post, we learnt about the Collectors mapping in Java with examples. We used it with other collectors as downstream collectors as well. Check out the other posts on collectors.

Leave a Reply