author-avatar
Dominik Martyniak
jOOQ 9 minut

Dynamiczne mapowanie rekordów w jOOQ z RecordMapper

W pracy z jOOQ często chcemy w prosty sposób przemapować wynik zapytania SQL na obiekt Javy (POJO). W większości przypadków wystarczy standardowe podejście z fetchInto(), które działa świetnie dla statycznych klas.

Problem pojawia się jednak wtedy, gdy struktura zapytania jest dynamiczna — np. nie znamy z góry kolumn lub chcemy mapować wynik na różne typy obiektów w zależności od kontekstu.

Wtedy warto sięgnąć po RecordMapper<Record, T>, który pozwala nam kontrolować proces mapowania.

W tym wpisie pokażę trzy praktyczne podejścia:

  • Prosty mapper z Builderem – szybki, bez refleksji
  • Mapper z cache metod buildera – dynamiczny i wydajny
  • Mapper generyczny – uniwersalny dla różnych typów obiektów

Klasa przykładowa – UserStats

Na potrzeby przykładu stwórzmy prostą klasę, która reprezentuje statystyki gracza:


public class UserStats {
    private final Long id;
    private final String nickname;
    private final Integer level;
    private final Double expRate;

    private PlayerStats(Builder builder) {
        this.id = builder.id;
        this.nickname = builder.nickname;
        this.level = builder.level;
        this.expRate = builder.expRate;
    }

    public static Builder builder() {
        return new Builder();
    }

    public static class Builder {
        private Long id;
        private String nickname;
        private Integer level;
        private Double expRate;

        public Builder id(Long id) { this.id = id; return this; }
        public Builder nickname(String nickname) { this.nickname = nickname; return this; }
        public Builder level(Integer level) { this.level = level; return this; }
        public Builder expRate(Double expRate) { this.expRate = expRate; return this; }

        public PlayerStats build() { return new PlayerStats(this); }
    }

    @Override
    public String toString() {
        return "PlayerStats{" +
                "id=" + id +
                ", nickname='" + nickname + '\'' +
                ", level=" + level +
                ", expRate=" + expRate +
                '}';
    }
}
 

Teraz zobaczmy, jak możemy dynamicznie mapować rekordy SQL do tego obiektu.


Prosty mapper z użyciem buildera

To najprostsze podejście – korzystamy bezpośrednio z metod buildera i sprawdzamy, które pola istnieją w rekordzie. Nie używamy refleksji, więc jest szybkie i bezpieczne.

 
import org.jooq.Record;
import org.jooq.RecordMapper;

public class SimplePlayerStatsMapper implements RecordMapper<Record, PlayerStats> {

    @Override
    public PlayerStats map(Record record) {
        var builder = PlayerStats.builder();

        if (record.field("id") != null)
            builder.id(record.get("id", Long.class));

        if (record.field("nickname") != null)
            builder.nickname(record.get("nickname", String.class));

        if (record.field("level") != null)
            builder.level(record.get("level", Integer.class));

        if (record.field("exp_rate") != null)
            builder.winRate(record.get("exp_rate", Double.class));

        return builder.build();
    }
}
 

Zalety:

  • Proste i bardzo czytelne
  • Bez refleksji – pełna kontrola i bezpieczeństwo typów
  • Idealne przy małej liczbie pól

Wady: trzeba ręcznie aktualizować mapper, gdy dodamy nowe pole.

 Przykład użycia:


List<PlayerStats> stats = dsl.select(PLAYER.ID, PLAYER.NICKNAME, PLAYER.LEVEL)
    .from(PLAYER)
    .fetch(new SimplePlayerStatsMapper());
  

Mapper refleksyjny z cache metod buildera

Drugie podejście jest bardziej dynamiczne – zamiast pisać warunki dla każdego pola, skanujemy metody buildera raz (np. id(), nickname(), level()) i zapisujemy je w cache. Dzięki temu refleksja jest używana tylko przy pierwszym uruchomieniu.

 
import org.jooq.Record;
import org.jooq.RecordMapper;

import java.lang.reflect.Method;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class CachedUserStatsMapper implements RecordMapper<Record, UserStats> {

    private static final Map<String, Method> BUILDER_METHODS = Stream
            .of(PlayerStats.builder().getClass().getMethods())
            .filter(m -> m.getParameterCount() == 1)
            .collect(Collectors.toMap(
                    m -> m.getName().toLowerCase(),
                    m -> m,
                    (a, b) -> a
            ));

    @Override
    public UserStats map(Record record) {
        var builder = UserStats.builder();

        for (var field : record.fields()) {
            var method = BUILDER_METHODS.get(field.getName().toLowerCase());
            if (method == null) continue;

            try {
                method.invoke(builder, record.getValue(field));
            } catch (Exception e) {
                throw new IllegalStateException("Failed to map field " + field.getName(), e);
            }
        }

        try {
            return (UserStats) builder.getClass().getMethod("build").invoke(builder);
        } catch (Exception e) {
            throw new IllegalStateException("Failed to build UserStats", e);
        }
    }
}
  

Zalety:

  • Dynamiczne mapowanie – działa dla dowolnych kolumn
  • Cache metod buildera – refleksja używana tylko raz
  • Działa nawet przy zapytaniach z aliasami (np. AS)

bardziej złożony kod, mniejsze bezpieczeństwo typów.

🔍 Przykład użycia:


List<UserStats> stats = dsl.select(USER.ID.as("id"), USER.NICKNAME, USER.LEVEL, USER.WIN_RATE)
    .from(USER)
    .fetch(new CachedUserStatsMapper());
  

Ulepszenie – mapper generyczny dla dowolnej klasy z builderem

Oba powyższe podejścia działają dobrze, ale możesz je połączyć w jedno i stworzyć generyczny mapper, który obsłuży dowolny typ z builderem.

import org.jooq.Record;
import org.jooq.RecordMapper;

import java.lang.reflect.Method;
import java.util.Map;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class GenericBuilderRecordMapper<T> implements RecordMapper<Record, T> {

    private final Supplier<?> builderSupplier;
    private final Class<T> targetClass;
    private final Map<String, Method> builderMethods;

    public GenericBuilderRecordMapper(Supplier<?> builderSupplier, Class<T> targetClass) {
        this.builderSupplier = builderSupplier;
        this.targetClass = targetClass;

        this.builderMethods = Stream.of(builderSupplier.get().getClass().getMethods())
                .filter(m -> m.getParameterCount() == 1)
                .collect(Collectors.toMap(
                        m -> m.getName().toLowerCase(),
                        m -> m,
                        (a, b) -> a
                ));
    }

    @Override
    public T map(Record record) {
        Object builder = builderSupplier.get();

        for (var field : record.fields()) {
            var method = builderMethods.get(field.getName().toLowerCase());
            if (method == null) continue;

            try {
                method.invoke(builder, record.getValue(field));
            } catch (Exception ignored) { }
        }

        try {
            return targetClass.cast(builder.getClass().getMethod("build").invoke(builder));
        } catch (Exception e) {
            throw new IllegalStateException("Cannot build target object", e);
        }
    }
}
  

🔍 Użycie generycznego mappera:


var mapper = new GenericBuilderRecordMapper<>(UserStats::builder, UserStats.class);

List<UserStats> stats = dsl
    .select(USER.ID.as("id"), USER.NICKNAME, USER.LEVEL, USER.EXP_RATE)
    .from(USER)
    .fetch(mapper);
  

Dzięki temu możesz używać jednego mappera dla wszystkich obiektów z builderem w swoim projekcie.


Porównanie podejść

Podejście Zalety Wady
Simple builder mapper Prosty, szybki, bez refleksji Wymaga ręcznego dodawania pól
Cached builder mapper Dynamiczny, elastyczny, wydajny Bardziej złożony kod
Generic builder mapper Uniwersalny – działa z dowolną klasą z builderem Najwięcej kodu, ale najwyższa reużywalność

 

Podsumowanie

jOOQ RecordMapper to bardzo elastyczny mechanizm, który pozwala nam w pełni kontrolować mapowanie danych. W połączeniu ze wzorcem Builder możemy budować czysty, bezpieczny i dynamiczny kod bez nadmiarowych setterów.

Jeśli zależy Ci na prostocie – wybierz wersję z ręcznym builderem.
Jeśli chcesz automatyzacji – użyj wersji cache'owanej lub generycznej.
Każde z tych rozwiązań świetnie uzupełnia możliwości jOOQ

1
[jooq]

Więcej od Dominik Martyniak

Więcej artykułów