Search code examples
javajava-8java-streamcollectors

Merging lists under same objects in a list using Java streams


I have two objects like following:

public class A {
    private Integer id;
    private String name;
    private List<B> list;

    public A(Integer id, String name, List<B> list) {
        this.id = id;
        this.name = name;
        this.list = list;
    }

    //getters and setters
}

and

public class B {
    private Integer id;
    private String name;

    public B(Integer id, String name) {
        this.id = id;
        this.name = name;
    }

    //getters and setters
}

So, A holds a list of B and there is a list of A populated as follows:

    List<A> list = new ArrayList<>();
    list.add(new A(1, "a_one", Arrays.asList(new B(1, "b_one"), new B(2, "b_two"))));
    list.add(new A(2, "a_two", Arrays.asList(new B(2, "b_two"))));
    list.add(new A(1, "a_one", Arrays.asList(new B(3, "b_three"))));
    list.add(new A(2, "a_two", Arrays.asList(new B(4, "b_four"), new B(5, "b_five"))));
    list.add(new A(3, "a_three", Arrays.asList(new B(4, "b_four"), new B(5, "b_five"))));

I want to acquire a new list by merging A objects with same ids. Result list must be like that:

[
    A(1, a_one, [B(1, b_one), B(2, b_two), B(3, b_three)]),
    A(2, a_two, [B(2, b_two), B(4, b_four), B(5, b_five)]),
    A(3, a_three, [B(4, b_four), B(5, b_five)])
]

I did manage to merge the list with the following code:

List<A> resultList = new ArrayList<>();
list.forEach(a -> {
    if (resultList.stream().noneMatch(ai -> ai.getId().equals(a.getId()))) {
        a.setList(list.stream().filter(ai -> ai.getId().equals(a.getId()))
                .flatMap(ai -> ai.getList().stream()).collect(Collectors.toList()));
        resultList.add(a);
    }
});

My question is, is there any proper way to do this by using stream collectors?


Solution

  • If you don't want to use extra functions you can do the following, it's readable and easy to understand, first group by id, create a new object with the first element in the list and then join all the B's classes to finally collect the A's.

    List<A> result = list.stream()
        .collect(Collectors.groupingBy(A::getId))
        .values().stream()
        .map(grouped -> new A(grouped.get(0).getId(), grouped.get(0).getName(),
                grouped.stream().map(A::getList).flatMap(List::stream)
                    .collect(Collectors.toList())))
        .collect(Collectors.toList());
    

    Another way is to use a binary operator and the Collectors.groupingBy method. Here you use the java 8 optional class to create the new A the first time when fst is null.

    BinaryOperator<A> joiner = (fst, snd) -> Optional.ofNullable(fst)
        .map(cur -> { cur.getList().addAll(snd.getList()); return cur; })
        .orElseGet(() -> new A(snd.getId(), snd.getName(), new ArrayList<>(snd.getList())));
    
    Collection<A> result = list.stream()
        .collect(Collectors.groupingBy(A::getId, Collectors.reducing(null, joiner)))
        .values();
    

    If you don't like to use return in short lambdas (doesn't look that well) the only option is a filter because java does not provide another method like stream's peek (note: some IDEs highlight to 'simplify' the expression and mutations shouldn't be made in filter [but i think in maps neither]).

    BinaryOperator<A> joiner = (fst, snd) -> Optional.ofNullable(fst)
        .filter(cur -> cur.getList().addAll(snd.getList()) || true)
        .orElseGet(() -> new A(snd.getId(), snd.getName(), new ArrayList<>(snd.getList())));
    

    You can also use this joiner as a generic method and create a left to right reducer with a consumer that allows to join the new mutable object created with the initializer function.

    public class Reducer {
        public static <A> Collector<A, ?, A> reduce(Function<A, A> initializer, 
                                                    BiConsumer<A, A> combiner) {
            return Collectors.reducing(null, (fst, snd) -> Optional.ofNullable(fst)
                .map(cur -> { combiner.accept(cur, snd); return cur; })
                .orElseGet(() -> initializer.apply(snd)));
        }
        public static <A> Collector<A, ?, A> reduce(Supplier<A> supplier, 
                                                    BiConsumer<A, A> combiner) {
            return reduce((ign) -> supplier.get(), combiner);
        }
    }
    

    And use it like

    Collection<A> result = list.stream()
        .collect(Collectors.groupingBy(A::getId, Reducer.reduce(
            (cur) -> new A(cur.getId(), cur.getName(), new ArrayList<>(cur.getList())),
            (fst, snd) -> fst.getList().addAll(snd.getList())
        ))).values();
    

    Or like if you have an empty constructor that initializes the collections

    Collection<A> result = list.stream()
        .collect(Collectors.groupingBy(A::getId, Reducer.reduce(A::new,
            (fst, snd) -> {
                fst.getList().addAll(snd.getList());
                fst.setId(snd.getId());
                fst.setName(snd.getName());
            }
        ))).values();
    

    Finally, if you already have the copy constructor or the merge method mentioned in the other answers you can simplify the code even more or use the Collectors.toMap method.