Grouping the Same Products in Java
Published: 2024-04-14 03:23:43

First, one must understand the relationship between Product and Item. A Product can have multiple Items. Simply put, a Product is the product information, and an Item is the option information.
```java
import java.util.List;

import lombok.Builder;
import lombok.Value;

@Value
@Builder
public class Product {
	String productId;

	List<Item> items;

	@Value
	@Builder
	public static class Item {
		String itemId;

		long itemQuantity;
	}
}
```

In the sample products, there are a total of three Products, two of which have the same productId. At the Item level, itemId 1 and 2 also exist as duplicates. Ultimately, the goal is to group by the same productId and the same itemId.
```java
public class Main {
  public static void main(String[] args) {
    /**
     * [
     *   Product(
     *     productId=1,
     *     items=[
     *       Product.Item(itemId=1, itemQuantity=1),
     *       Product.Item(itemId=1, itemQuantity=1),
     *       Product.Item(itemId=2, itemQuantity=2)
     *     ]
     *   ),
     *   Product(
     *     productId=1,
     *     items=[
     *       Product.Item(itemId=3, itemQuantity=3),
     *       Product.Item(itemId=2, itemQuantity=2)
     *     ]
     *   ),
     *   Product(
     *     productId=2,
     *     items=[
     *       Product.Item(itemId=5, itemQuantity=5)
     *     ]
     *   )
     * ]
     */
     List<Product> products = getProducts();
  }

  private static List<Product> getProducts() {
    return List.of(
      Product.builder()
        .productId("1")
        .items(List.of(
          Item.builder()
            .itemId("1")
            .itemQuantity(1)
            .build(),
          Item.builder()
            .itemId("1")
            .itemQuantity(1)
            .build(),
          Item.builder()
            .itemId("2")
            .itemQuantity(2)
            .build()
        )
      )
      .build(),
      Product.builder()
        .productId("1")
        .items(List.of(
          Item.builder()
            .itemId("3")
            .itemQuantity(3)
            .build(),
          Item.builder()
            .itemId("2")
            .itemQuantity(2)
            .build()
        )
      )
      .build(),
      Product.builder()
        .productId("2")
        .items(List.of(
          Item.builder()
            .itemId("5")
            .itemQuantity(5)
            .build()
          )
        )
      .build()
    );
  }
}
```

The ProductGroup class below holds the results of grouping by the same productId and the same itemId. It can have multiple ItemGroups per productId without duplicates, and the itemId within the ItemGroup also does not duplicate. Finally, the itemQuantity for the same itemId is summed up.
```java
@Value
@Builder(access = AccessLevel.PRIVATE)
public class ProductGroup {
	String productId;

	List<ItemGroup> itemGroups;

	@Value
	@Builder(access = AccessLevel.PRIVATE)
	public static class ItemGroup {
		String itemId;

		long totalQuantity;
	}
}
```

Let's now look at the logic for grouping. The first thing to do is to group the Products by productId.
```java
public static List<ProductGroup> grouping(List<Product> products) {
  return products.stream()
    // first process Map<String, List<Product>>
    .collect(Collectors.groupingBy(Product::getProductId, toList()))
    
    
    .entrySet() // Set<Map<K,V>.Entry<String, List<Product>>>
    .stream() // Stream<Map<K,V>.Entry<String, List<Product>>>
    
    // second process
    .map(ProductGroup::makeEachProductGroups) // Stream<ProductGroup>
    .toList();
}
```

After performing the first process, it is stored in a Map as described in the comment below. Using this Map, a ProductGroup is created by utilizing `entrySet().stream()`.
```java
public static List<ProductGroup> grouping(List<Product> products) {
  return products.stream()

    /**
     * first process : Map<String, List<Product>> productListsById
     * {
     * 1=[
     *     Product(
     *       productId=1,
     *       items=[
     *         Product.Item(itemId=1, itemQuantity=1),
     *         Product.Item(itemId=1, itemQuantity=1),
     *         Product.Item(itemId=2, itemQuantity=2)
     *       ]
     *     ),
     *     Product(
     *       productId=1,
     *       items=[
     *         Product.Item(itemId=3, itemQuantity=3),
     *         Product.Item(itemId=2, itemQuantity=2)
     *       ]
     *     )
     *   ],
     * 2=[
     *     Product(
     *       productId=2,
     *       items=[Product.Item(itemId=5, itemQuantity=5)]
     *     )
     *   ]
     * }
     */
    .collect(Collectors.groupingBy(Product::getProductId, toList()))
    
    .entrySet() // Set<Map<K,V>.Entry<String, List<Product>>>
    .stream() // Stream<Map<K,V>.Entry<String, List<Product>>>
    
    // second process
    .map(ProductGroup::makeEachProductGroups) // Stream<ProductGroup>
    .toList();
```

cf) If toUnmodifiableMap is used as shown below, a `duplicateKeyException` occurs if there are duplicate productIds.
```java
Map<String, Product> productsById = products.stream()
.collect(Collectors.toUnmodifiableMap(Product::getProductId, Function.identity()));
```

In the subsequent secondary task, a ProductGroup is created. Then, moving on to the ItemGroup stage (makeItemGroups), the Items within a Product are first turned into a Stream using `flatMap(product -> product.getItems().stream())`, and then grouped again by itemId.

Using the results, an ItemGroup is created, and the totalQuantity is calculated by summing the quantities of the grouped Items.
```java
private static ProductGroup makeEachProductGroups(Entry<String, List<Product>> productListById) {
  return ProductGroup.builder()
    .productId(productListById.getKey())
    .itemGroups(makeItemGroups(productListById.getValue()))
    .build();
  }

private static List<ItemGroup> makeItemGroups(List<Product> products) {
  return products.stream() // Stream<Product>
    .flatMap(product -> product.getItems().stream()) // Stream<Product.Item>
    .collect(groupingBy(Item::getItemId, toList())) // Map<String, List<Product.Item>>
    .entrySet().stream() // Stream<Map<K,V>.Entry<String, List<Product.Item>>>
    .map(it ->
      ItemGroup.builder()
        .itemId(it.getKey())
        .totalQuantity(it.getValue().stream().mapToLong(Item::getItemQuantity).sum())
        .build()
    ) // Stream<ProductGroup.ItemGroup>
    .toList();
  }
```

The final result obtained from the grouping is as follows.
```
	/**
	 * second process : grouping
	 * [
	 *   ProductGroup(
	 *     productId=1,
	 *     itemGroups=[
	 *       ProductGroup.ItemGroup(itemId=1, totalQuantity=2),
	 *       ProductGroup.ItemGroup(itemId=2, totalQuantity=4),
	 *       ProductGroup.ItemGroup(itemId=3, totalQuantity=3)
	 *     ]
	 *   ),
	 *   ProductGroup(
	 *     productId=2,
	 *     itemGroups=[
	 *       ProductGroup.ItemGroup(itemId=5, totalQuantity=5)
	 *     ]
	 *   )
	 * ]
	 */
```

There's one important point to note in the makeItemGroups logic. After turning the Items within a Product into a Stream using `flatMap(product -> product.getItems().stream())`, it is essential to group them again by itemId.

It should not be done by grouping using the items of each product as follows.
```java
private static List<ItemGroup> makeItemGroupsWrongWay(List<Product> products) {
  return products.stream()
    .flatMap(product -> // don't do that each product
      product.getItems().stream()
        .collect(groupingBy(Item::getItemId, toList()))
        .entrySet()
        .stream()
        .map(it ->
          ItemGroup.builder()
            .itemId(it.getKey())
            .totalQuantity(it.getValue().stream().mapToLong(Item::getItemQuantity).sum())
            .build()
        ))
        .toList();
}
```