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, g
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