Search code examples
javajooq

jOOQ Record to DTO Mapping in Abstraction


i wrote a DAO-Abstraction that all my DAOs extend from. This DAO-Abstraction provides typical remote-pagination with a common query-param model.

It fetches joined-data from multiple tables. This joined-data is then given to the concrete DAO in form of a List<Record> records parameter, and now the developer must convert those Record instances to the DTO instances (where a DTO-instance may need to be constructed from multiple Record-instances). Here is an example:

public class ProductViewDAO extends AbstractViewDAO<ProductRecord, ProductDTO, Long> {
...
    @Override
    protected TableOnConditionStep<Record> getViewJoins() {
        return Product.PRODUCT
                .leftJoin(ProductLang.PRODUCT_LANG)
                .on(ProductLang.PRODUCT_LANG.PRODUCTID
                        .eq(Product.PRODUCT.PRODUCTID));
    }

    @Override
    protected List<ProductDTO> recordsToView(List<Record> records) {
        List<ProductDTO> products = new ArrayList<>();
        Map<Long, ProductDTO> productMap = new HashMap<>();

        for (Record record : records) {
            Long productId = record.get(Product.PRODUCT.PRODUCTID);
            ProductDTO productDTO = productMap.get(productId);

            if (productDTO == null) {
                ProductRecord productRecord = record.into(new ProductRecord());
                productDTO = productRecord.into(new ProductDTO());
                List<ProductLangDTO> xLangs = new ArrayList<>();
                productDTO.setLangs(xLangs);
                products.add(productDTO);
                productMap.put(productId, productDTO);
            }
            ProductLangRecord xLangRecord = record.into(new ProductLangRecord());
            ProductLangDTO xLang = xLangRecord.into(new ProductLangDTO());
            productDTO.getLangs().add(xLang);

            if (xLang.getLangId().equals(requestContext().getLangId())) {
                productDTO.setLang(xLang);
            }
        }
        return products;
    }
...
}
...
public abstract class AbstractViewDAO<R extends UpdatableRecord<R>, P extends AbstractDTO, T> extends AbstractBaseDAO<R, T> {
...
  public List<P> query(final QueryParameters queryParameters) throws DataAccessException {
...
    var result = getViewQuery().where(equal(pk, ids)).fetch();
    return recordsToView(result);
  }
}



how can this be done easier?

Does Java or jOOQ provide Helper-Classes that can help automate such a mapping of Record to DTO ?


Solution

  • This can be solved in the most convinient way, by using MULTISET (as suggested by @LukasEder).

    • lets the database (or jooq) do the work of retrieving and combining the results to map them to the expected result types.
    • no (or few) own mappings needed.

    But to use MULTISET here, we need to change/refactor the initial concept or remote-paginated filtering with given query-params.

    I found two way to do this...

    Two Separate Queries

    1. Construct a joined query, that filters all related tables and gets the grouped ids of the main-table. For example (only pseudo-code, the resolvement of the filtering/sorting is defined in separate functions):
    TableOnConditionStep viewJoins = Product.PRODUCT
        .leftJoin(ProductLang.PRODUCT_LANG)
        .on(ProductLang.PRODUCT_LANG.PRODUCTID
        .eq(Product.PRODUCT.PRODUCTID));
    
    List<Long> productIds = dsl
        .select(Product.PRODUCT.PRODUCTID)
        .from(viewJoins)
        .where(DSL.and(getFilters(queryParameters)))
        .groupBy(Product.PRODUCT.PRODUCTID)
        .orderBy(getSorters(queryParameters))
        .offset(queryParameters.getPage())
        .limit(queryParameters.getPageSize())
        .fetch(Product.PRODUCT.PRODUCTID);
    

    this first query will do all the filtering and sorting as given by the frontend, and will also remote-paginate. It will only select the grouped ids of the main-table (here productIds) of the paginate-window. This will be a very small number of productIds, only as large as the paginate-window (5, 25, 50)

    • Sorting-Order of Sort-Criterias that are applied to joined tables are also correctly applied that way by the database.
    • Filtering on fields of the joined tables is also applied that way by the database.
    1. The second query, now will get all the results for those small number of productIds, with help of MULTISET:
    // use multiset to let the database and jooq do all the work of joining the tables and mapping to dto.
    List<ProductDTO> products = dsl.select(
                    asterisk(),
                    multiset(
                            selectDistinct(asterisk())
                                    .from(ProductLang.PRODUCT_LANG)
                                    .where(ProductLang.PRODUCT_LANG.PRODUCTID.eq(Product.PRODUCT.PRODUCTID))
                    ).as("langs")
            ).from(Product.PRODUCT)
            .where(Product.PRODUCT.PRODUCTID.in(productIds))
            .fetchInto(ProductDTO.class);
    
    // append and change some data that is still missing after our fetch.
    for (ProductDTO product : products) {
        for (ProductLangDTO productLang : product.getLangs()) {
            if (productLang.getLangId().equals(request.getLangId())) {
                product.setLang(productLang);
            }
        }
    }
    return products;
    

    By splitting this taks into two parts, we also have a helpful function that we can always use to get a complete resolved model for a small number of productIds. This will be also helpful when doing Streaming, because when we will Stream on the database, we also will stream only a small number of items (250, 500) per block, and can reuse this function.

    the biggest win here, is (in my opinion) that the database will at no point return a Kartesisches Produkt for the Table-Relationships, when returning the results.

    A very small loss is, that we need to send two separate queries to the database

      1. Filter/Sort/Paginate to find productIds
      1. Retrieve the Models for the given productIds

    At the same time, this is a win, because it allows us to reuse this logic for other purposes (streaming).

    One Single Query

    Instead of using two separate queries (like shown in the above example), everything can also be done in one combined query:

    • Filter/Sort/Paginate
    • Retrieve the Models

    In this case it is not necessary to fetch intermediate Ids, but the Models will be fetched directly (please note: the queryJooqMapper is a helper class that contains the Filters/Sort/Paginate Settings, but you can just as well pass them directly).

    List<ProductDTO> products = dsl.select(
                    asterisk(),
                    multiset(
                            selectDistinct(
                                    asterisk(),
                                    jsonObject(asJsonEntries(Lang.LANG.fields())).as("lang")
                            )
                                    .from(ProductLang.PRODUCT_LANG)
                                    .join(Lang.LANG).on(Lang.LANG.LANGID.eq(ProductLang.PRODUCT_LANG.LANGID))
                                    .where(ProductLang.PRODUCT_LANG.PRODUCTID.eq(Product.PRODUCT.PRODUCTID))
                    ).as("langs"))
            .from(Product.PRODUCT
                    .leftJoin(ProductLang.PRODUCT_LANG)
                    .on(ProductLang.PRODUCT_LANG.PRODUCTID
                            .eq(Product.PRODUCT.PRODUCTID))
                    .leftJoin(Lang.LANG)
                    .on(Lang.LANG.LANGID
                            .eq(ProductLang.PRODUCT_LANG.LANGID)))
            .where(queryJooqMapper.getFilters())
            .and(Product.PRODUCT.CLIENTID.eq(request().getClientId()))
            .groupBy(Product.PRODUCT.PRODUCTID)
            .orderBy(queryJooqMapper.getSorter())
            .offset(queryJooqMapper.getOffset())
            .limit(queryJooqMapper.getLimit());
    

    The important part here is, that we need to construct the Joins on all the Tables that we need to Sort/Filter (Deep Joins). The database (here: Mariadb) will first resolve those Sort/Filters on the Joins, afterwards the result is Grouped on the ID of the Main-Table (here: Product). That way we will flatten the "kartesisches Produkt" after! we have filtered/sorted, so we are fulfilling the Limit/Offset criteria to only return the specific numbers of rows of the main-table (but they are filtered/sorted by the joined tables). And finally (with help of MULTISET) we will resolve all the Deep Data from the Relevant Tables (this mostly will be the same tables as the ones we have joined for filtering/sorting), and this Deep Data is then mapped by Jooq automatically to the List result.