1

I have a List<MyObject>

class MyObject {
    String loanType;
    String loanCurrency;
    BigDecimal amountPaid;
    BigDecimal amountRemaining;
}

And I need to convert this list into a map Map<String, Map<String, MySumObject>.

I've created a custom MySumObject class because I need to get a Sum for both amountPaid and amountRemaining from the list of MyObject based on the loanType and loanCurrency.

class MySumObject {
    BigDecimal paidSum;
    BigDecimal remainingSum;
}

Using the code below, I can obtain a Map<String,Map<String, BigDecimal>>

Map<String, Map<String, BigDecimal>> result = list1.stream().collect(
    Collectors.groupingBy(LoanObject::getLoanType,
        Collectors.groupingBy(LoanObject::getLoanCurrency,
            Collectors.reducing(
                BigDecimal.ZERO,
                LoanObject::getAmountPaid,
                BigDecimal::add)
        )
    ));

But I'm stuck on changing it to use MySumObject instead of BigDecimal.

Alexander Ivanchenko
  • 25,667
  • 5
  • 22
  • 46
jasmeet
  • 37
  • 4

2 Answers2

1

Using MySumObject as an accumulation type would make sense only if all the payment operation represented by MyObject in the source list belong to different loans.

Otherwise, there's a flow in your logic - remaining amount of a particular loan decreases after each payment. It's pointless to calculate the sum of remaining amounts of the same loan. Instead, we can grab the data regarding remaining amount from the loan object with the latest timestamp or having the lowest remaining amount, but that requires different strategy of grouping and different structure of loan object (MyObject), i.e. should have at least loanId.

That said, correctness of your logic - is your responsibility.

Let's get back to the initial idea of accumulating loans having the same currency and loan type into MySumObject (I assume that you know what you're doing, and it does make sense).

It can be achieved by creating a custom collector based on the MySumObject:

Map<String, Map<String, MySumObject>> result = list1.stream()
    .collect(Collectors.groupingBy(
        MyObject::getLoanType,
        Collectors.groupingBy(
            MyObject::getLoanCurrency,
                Collector.of(
                    MySumObject::new,
                    MySumObject::addLoan,
                    MySumObject::merge
                ))
        ));

MySumObject with methods addLoan() and merge().

public static class MySumObject {
    private BigDecimal paidSum = BigDecimal.ZERO;
    private BigDecimal remainingSum = BigDecimal.ZERO;
    
    public void addLoan(LoanObject loan) {
        paidSum = paidSum.add(loan.getAmountPaid());
        remainingSum = remainingSum.add(loan.getAmountRemaining());
    }
    
    public MySumObject merge(MySumObject other) {
        paidSum = paidSum.add(other.getPaidSum());
        remainingSum = remainingSum.add(other.getRemainingSum());
        return this;
    }
    
    // getters, constructor, etc. are omitted
}

Sidenote: it doesn't seem to be justifiable to use a string to represent a currency (unless it's not an assignment requirement) because since Java 1.4 we have a class Currency.

Alexander Ivanchenko
  • 25,667
  • 5
  • 22
  • 46
  • How to set the initialize value for those two fields in MySumObject? Otherwise, it will throw NPE. – Abe Aug 11 '22 at 18:52
  • @yulinxp That's a good point. In order to use `MySumObject` as a *mutable container*, by default, these fields should be initialized with a value of `BigDecimal.ZERO`. Updated the answer. – Alexander Ivanchenko Aug 11 '22 at 19:01
0

After creating the nested map via groupingBy you would normally end up with a list of MyObject. But you want to sum those values so you need to use CollectingAndThen with a finisher. In this case the finisher streams each list and applies a reduction to build the final object. In my solution I simply created a new MySumObject each time, adding the previouly computed MySumObject, to the next MyObject value from the list. This method is quite common and has advantages in that it also works with immutable containers such as records and does not require additional modification to your classes outside of the standard getters used to retrieve the fields. To avoid clutter, I created a helper method to do the reduction. But the actual summation code could have been put in the collector.

Map<String, Map<String, MySumObject>> map = list.stream()
        .collect(Collectors.groupingBy(
                MyObject::getLoanType,
                Collectors.groupingBy(
                        MyObject::getLoanCurrency,
                        Collectors.collectingAndThen(
                                Collectors.toList(),
                                lst->summing(lst)))));

Helper method to reduce the list to a single object. Since the reduction works on two different types, the three argument version of reduce is used. First the target MySumObject is initialized with zero sums. Then the reduction creates the new MySumObject and adds each appropriate MyObject to it. The third argument would be used for parallel processing to sum the different MySumObjects from different threads. In this case, I just assigned the already computed value assuming a single stream.

public static MySumObject summing(List<MyObject> list) {
    return list.stream()
    .reduce(new MySumObject(
            BigDecimal.ZERO,
            BigDecimal.ZERO),
            (MySumObject mySum, MyObject myOb) -> new MySumObject(
                    myOb.getAmountPaid()
                            .add(mySum.getPaidSum()),
                    myOb.getAmountRemaining()
                            .add(mySum.getRemainingSum())),(a,b)->a);
}

As an alternative, you could also do it like this which I believe would be more efficient since you are summing as each MyObject is referenced. It takes advantage of the Java 8+ map enhancements. ComputeIfAbsent creates the value if the key is missing. It will return that value or the existing one. Since that value is also a map, compute can be applied. That will also create the value if the key is missing or process the existing value.

Map<String, Map<String, MySumObject>> map = new HashMap<>();
for (MyObject mo : list) {
    map.computeIfAbsent(mo.getLoanType(),
            v -> new HashMap<String, MySumObject>())
            .compute(mo.getLoanCurrency(),
                    (k, v) -> v == null ?
                            new MySumObject(
                                    mo.getAmountPaid(),
                                    mo.getAmountRemaining()) :
                            new MySumObject(
                                    v.getPaidSum().add(mo
                                            .getAmountPaid()),
                                    v.getRemainingSum().add(mo
                                            .getAmountRemaining())));
}

For the following data, the output is shown and would be identical for both methods.

public static void main(String[] args) {
    List<MyObject> list = List.of(
            new MyObject("Type1", "Currency1",
                    BigDecimal.valueOf(10),
                    BigDecimal.valueOf(100)),
            new MyObject("Type2", "Currency2",
                    BigDecimal.valueOf(20),
                    BigDecimal.valueOf(200)),
            new MyObject("Type1", "Currency1",
                    BigDecimal.valueOf(30),
                    BigDecimal.valueOf(300)),
            new MyObject("Type2", "Currency2",
                    BigDecimal.valueOf(40),
                    BigDecimal.valueOf(400)));

map.entrySet().forEach(System.out::println);

prints

Type2={Currency2=MySumObject[getPaidSum=60, getRemainingSum=600]}
Type1={Currency1=MySumObject[getPaidSum=40, getRemainingSum=400]}
WJS
  • 36,363
  • 4
  • 24
  • 39
  • For summing() method, how does combiner (a,b)->a work? Does it need something like (mySumObject1, mySumObject2) -> { mySumObject1.setPaidSum(mySumObject1.getPaidSum().add(mySumObject2.getPaidSum()));...return mySumObject1;} – Abe Aug 11 '22 at 20:32
  • Exactly. A and B are the same type. So if you were doing this in parallel that would be the combiner to add the individual sums from the different threads. BTW, If I thought you wanted a different solution I would have provided it. I was just trying to address your immediate problem as you asked it without adding special methods to your classes. – WJS Aug 11 '22 at 22:02