Search code examples
javacollectionshashmapjava-streamcollectors

Create a map from a 2 level nested list where key is part of the nested list's object?


I have a simple nested structure as such:

public static class A {
    private List<B> classBList;

    // constructor, getters, etc.
}

public static class B {
    private int id;
    private String name;

    // constructor, getters, etc.
}

I want to create a map of <Integer,List<A>> where the integer field in class B id will be the key, and the A objects in the input that contain the matching id will be rolled up into a list as the value. The input would be a list of class A.

So for example:

Input:

[classBList=[B{id:1, name:"Hello"}, B{id:2, name:"Hi"}],
classBList=[B{id:3, name:"No"}, B{id:3, name:"Go"}],
classBList=[B{id:1, name:"Yes"}]]

Output:

{Key=1, Value=[ A{classBList=[B{id:1, name:"Hello"}, B{id:1, name:"Yes"}]} ]

{Key=2, Value=[ A{classBList=[B{id:2, name:"Hi"}]} ]

{Key=3, Value=[ A{classBList=[B{id:3, name:"No"}, B{id:3, name:"Go"}]} ]

I'm having trouble, however, writing the lambdas that allow this to happen. What I tried:

Map<Integer, List<A>> heyThere = classAListInput.stream()
    .collect(Collectors.toMap(
        A::getClass,
        element -> element.getClassBList().stream()
            .map(B::getId)
            .collect(Collectors.toList())
    ));

But this doesn't compile, so really not sure of how the syntax should look.

If you're wondering why not just alter the map so it's <Integer, List< B >>, there are other fields in class A that I didn't note but would be needed in the output, so that's why a list of A objects would be the value in the map.


Solution

  • It seems that you need to rebuild instances of A class with the new list of B.

    However, expected output shows that there is only one A entry in the list, and all B's are added to the same A instance:

    {Key=2, Value=[ A{classBList=[B{id:2, name:"No"}, B{id:2, name: "Go"}, B{id:2, name:"Yellow"}, B{id:2, name:"Joe"}, B{id:2, name:"Blow"}]} ]
    

    So, the following implementation may be offered assuming there's an all-args constructor in A class accepting List<B>:

    Map<Integer, List<A>> result = classAListInput
        .stream() // Stream<A>
        .flatMap(a -> a.getClassBList().stream()) // Stream<B>
        .collect(Collectors.groupingBy(
            B::getId,
            Collectors.collectingAndThen(
                Collectors.toList(), // List<B> flattening all B instances by id
                lst -> List.of(new A(lst)) // or Arrays.asList or Collections.singletonList
            )
        ));
    

    Update

    As the copies of A class may need to be created with some additional fields in A class and the filtered list of B instances, the following change may be offered using Map.entry (available since Java 9 which has been around for a while).

    Also, to address an issue of duplicated entries for the same bKey inside the same A instance, Stream::distinct operation should be applied after mapping to the key field:

    Map<Integer, List<A>> aByBKey = classAListInput.stream()
        .flatMap(a -> a.getClassBList()
            .stream()
            .map(B::getBKey)
            .distinct()
            .map(bk -> Map.entry(bk, getCopy(a, bk)) )
        )
        .collect(Collectors.groupingBy(
            Map.Entry::getKey,
            Collectors.mapping(Map.Entry::getValue, Collectors.toList())
        ));
    
    aByBKey.forEach((bKey, a) -> System.out.println(bKey + " -> " + a));
    

    where getCopy is a static method constructing a copy instance of A keeping the code of class A intact:

    public static A getCopy(A a, int bKey) {
        return new A(
            a.aKey, 
            a.classBList
                .stream()
                .filter(b -> b.getBKey() == bKey)
                .collect(Collectors.toList())
        );
    }
    

    JDoodle example