Search code examples
javacollectionsjava-stream

Java Stream: map and collect non-normalized entities to map of normalized dtos


I have a db table that repeats lots of data and is represented by the class FooEntity. Foo should actually have a 1-N relationship with Bar, but they're all stored in the same table replicating foo data. I can't change the table as it's already being used.

class FooEntity {
    private Long id;
    private Long keyValue;
    private String fooField; //other foo fields, duplicated
    private String barField; //other bar fields
}

I need to query data from this table and map them to a collection of DTOs that follows the 1-N relationship, eg

class FooDTO {
    private String fooField; //other foo fields
    private List<BarDTO> bars;
}
class BarDTO {
    private Long id;
    private String barField; //other bar fields
}

I also need to group this collection of FooDTO by their keyValue, putting them in a Map<Long, List<FooDTO>>.

I have found a solution but I'm not sure it's a good one, is there a better way? Especially one that doesn't keep opening and closing streams and doesn't need 3 dtos, as I think it can be done with 2. Should I use different collectors, or do these operations in a different order?

Here's my implementation

class FooKeyDTO {
    String fooField; //other foo fields
}
class FooDTO extends FooKeyDTO {
    private List<BarDTO> bars;
    public FooDTO(FooKeyDTO foo, List<BarDTO> bars) {
        this.fooField = foo.fooField; //other foo fields
        this.bars = bars;
    }
}
class BarDTO {
    private Long id;
    private String barField; //other bar fields
}

Map<Long, List<FooDTO>> value = jpaRepository
    .findByMyField(myField) //Set<FooEntity>
    .stream()
    .collect(Collectors.groupingBy(s -> s.getKeyValue())) //Map<Long, List<FooEntity>>
    .entrySet()
    .stream()
    .collect(Collectors.toMap(
        Entry::getKey,
        foo -> foo.getValue() //List<FooEntity>
            .stream()
            .collect(Collectors.groupingBy(
                k -> toFooKeyDTO(k), //FooKeyDTO
                Collectors.mapping(v -> toBarDTO(v), Collectors.toList()))) //List<BarDTO>
            .entrySet() //entries of Map<FooKeyDTO, List<BarDTO>>
            .stream()
            .map(f -> new FooDTO(f.getKey(), f.getValue()))
            .toList()
    ));

The query and the entity to dto converting methods are pretty simple and I don't think they're relevant to the question (I used a jpa repository and a model mapper to convert).

An example: From these entities

{
    "id": 1,
    "keyValue": 100,
    "fooField": "foo1",
    "barField": "bar1"
},
{
    "id": 2,
    "keyValue": 100,
    "fooField": "foo1",
    "barField": "bar2"
},
{
    "id": 3,
    "keyValue": 100,
    "fooField": "foo2",
    "barField": "bar3"
},
{
    "id": 4,
    "keyValue": 100,
    "fooField": "foo2",
    "barField": "bar4"
},
{
    "id": 5,
    "keyValue": 100,
    "fooField": "foo2",
    "barField": "bar5"
},
{
    "id": 6,
    "keyValue": 200,
    "fooField": "foo3",
    "barField": "bar6"
},
{
    "id": 7,
    "keyValue": 200,
    "fooField": "foo3",
    "barField": "bar7"
}

to this map

{
    "100": [
        {
            "fooField": "foo1",
            "bars": [
                {
                    "id": 1,
                    "barField": "bar1"
                },
                {
                    "id": 2,
                    "barField": "bar2"
                }
            ]
        },
        {
            "fooField": "foo2",
            "bars": [
                {
                    "id": 3,
                    "barField": "bar3"
                },
                {
                    "id": 4,
                    "barField": "bar4"
                },
                {
                    "id": 5,
                    "barField": "bar5"
                }
            ]
        }
    ],
    "200": [
        {
            "fooField": "foo3",
            "bars": [
                {
                    "id": 6,
                    "barField": "bar6"
                },
                {
                    "id": 7,
                    "barField": "bar7"
                }
            ]
        }
    ]
}

Solution

  • Here is one way to do it although I can't say it is better than yours. I am using records. Your classes with constructors and getters would also work.

    record FooEntity(Long getId, Long getKeyValue, String getFooField, // other foo
                                                                       // fields,
            // duplicated
            String getBarField) {
    }
    
    record BarDTO(Long getId, String getBarField // other bar fields
    ) {
    }
    
    record FooDTO(String getFooField, // other foo fields
            List<BarDTO> getBars) {
    }
    

    A List of your FooEntity class

    List<FooEntity> list = List.of(new FooEntity(1L, 100L, "foo1", "bar1"),
            new FooEntity(2L, 100L, "foo1", "bar2"),
            new FooEntity(3L, 100L, "foo2", "bar3"),
            new FooEntity(4L, 100L, "foo2", "bar4"),
            new FooEntity(5L, 100L, "foo2", "bar5"),
            new FooEntity(6L, 200L, "foo3", "bar6"),
            new FooEntity(7L, 200L, "foo3", "bar7"));
    

    In a two stage process, this builds a map of maps using computeIfAbsent which creates the entry if the key doesn't exist and returns the new or existing value.

    • outer map key - keyValue
    • inner map key - fooField
    • inner map value = FooDTO
    Map<Long, Map<String, FooDTO>> mapTemp = new HashMap<>();
    for (FooEntity fe : list) {
        mapTemp.computeIfAbsent(fe.getKeyValue(), v -> new HashMap<>())
                .computeIfAbsent(fe.getFooField(),
                     v -> new FooDTO(fe.getFooField(), // FooDTO instance
                     new ArrayList<>()))
                          .getBars()  // get the list from FooDTO
                                      // and add a new BarDTO instance
                          .add(new BarDTO(fe.getId(), 
                                          fe.getBarField()));
    }
    

    This prints the map

    mapTemp.entrySet().forEach(e -> {
        System.out.println(e.getKey());
        e.getValue().entrySet().forEach(ee -> {
            System.out.println("    " + ee.getKey());
            ee.getValue().getBars()
                    .forEach(v -> System.out.println("       " + v));
        });
    });
    

    prints

    100
        foo1
           BarDTO[getId=1, getBarField=bar1]
           BarDTO[getId=2, getBarField=bar2]
        foo2
           BarDTO[getId=3, getBarField=bar3]
           BarDTO[getId=4, getBarField=bar4]
           BarDTO[getId=5, getBarField=bar5]
    200
        foo3
           BarDTO[getId=6, getBarField=bar6]
           BarDTO[getId=7, getBarField=bar7]
    

    It's possible the above will be sufficient for your use. However, to convert that to the desired map, the following will work.

    Map<Long, List<FooDTO>> result = mapTemp.entrySet().stream()
       .collect(Collectors.toMap(
                           e -> e.getKey(),
                           e -> e.getValue().entrySet()
                                 .stream()
                                 .map(ee -> ee.getValue()).toList()));
    

    And it can be printed with the following, the output looking exactly like the above.

    result.entrySet().forEach(e -> {
        System.out.println(e.getKey());
        e.getValue().forEach(ob -> {
            System.out.println("    " + ob.getFooField());
            ob.getBars().forEach(b -> System.out.println("       " + b));
        });
    });