Dynamic Query pada Java, Part III: jOOQ

4 tahun lalu gw bikin dua tulisan tentang cara bikin query dinamis di Java. Pertama menggunakan Spring Data JPA, kemudian menggunakan menggunakan QueryDSL. Hari ini gw bakal lanjutin tentang cara bikin query dinamis menggunakan jOOQ. Untuk Spring Data JPA dan QueryDSL gw memang pernah menggunakannya di production. Tapi untuk jOOQ ini gw cuma sebatas research aja. Harusnya ga jauh beda manfaatnya. Di sini kita masih menggunakan contoh kasus yang sama, menggunakan tabel student dan student_batch.
Module Dependencies
Kita menggunakan plugin jooq-meta-extensions dan jooq-codegen-maven untuk generate utilities yang berfungsi untuk mengeksekusi sql ke database. Plugin tersebut membaca schema tabel yang ada di dalam src/main/resources/schema.sql. Class tersebut di-generate ke path target/generated-sources/jooq tiap kita eksekusi mvn clean install atau mvn generate-sources sama seperti QueryDSL. Atau kalau menggunakan Intellij bisa dengan cara klik kanan folder module yang digunakan -> pilih maven -> pilih generate sources and update folders. Pastikan juga tidak ada compile error di dalam module, agar codenya berhasil digenerate.
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>dynamic-query-with-jooq</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.jooq</groupId>
<artifactId>jooq</artifactId>
<version>3.19.34</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.jooq</groupId>
<artifactId>jooq-meta-extensions</artifactId>
<version>3.19.34</version>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
<version>42.7.5</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.42</version>
<scope>provided</scope>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.jooq</groupId>
<artifactId>jooq-codegen-maven</artifactId>
<version>3.19.34</version>
<executions>
<execution>
<id>generate-jooq</id>
<phase>generate-sources</phase>
<goals>
<goal>generate</goal>
</goals>
</execution>
</executions>
<configuration>
<generator>
<database>
<name>org.jooq.meta.extensions.ddl.DDLDatabase</name>
<properties>
<property>
<key>scripts</key>
<value>src/main/resources/schema.sql</value>
</property>
<property>
<key>sort</key>
<value>semantic</value>
</property>
<property>
<key>defaultNameCase</key>
<value>lower</value>
</property>
</properties>
</database>
<target>
<packageName>com.example.soclean.repository.user.generated</packageName>
<directory>target/generated-sources/jooq</directory>
</target>
</generator>
</configuration>
<dependencies>
<dependency>
<groupId>org.jooq</groupId>
<artifactId>jooq-meta-extensions</artifactId>
<version>3.19.34</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
</project>Lalu kita siapkan script DDL di src/main/resources/schema.sql untuk generate utilities jOOQ.
CREATE TABLE student_batch (
id BIGSERIAL PRIMARY KEY,
batch_major VARCHAR(255) NOT NULL,
batch_name VARCHAR(15) NOT NULL,
batch_year INTEGER NOT NULL,
version INTEGER,
CONSTRAINT unique_batches UNIQUE (batch_year, batch_major)
)
;
CREATE TABLE student (
id BIGSERIAL PRIMARY KEY,
is_active BOOLEAN NOT NULL,
birth_date DATE NOT NULL,
nik VARCHAR(16) NOT NULL
CONSTRAINT uk_j0gf6qfh38s97b43tgamw90hb UNIQUE,
npm VARCHAR(10) NOT NULL
CONSTRAINT uk_hb21rm231whmvidn1hpbatqxe UNIQUE,
student_batch_id BIGINT
CONSTRAINT fk2oro1x1ru5b06gv863phb22ut REFERENCES student_batch,
student_name VARCHAR(25) NOT NULL,
version INTEGER
)
;Setelah mvn clean install, tiap ada perubahan di tabel kita hanya perlu execute mvn generate-sources agar codegennya terupdate. Sebenarnya ada beberapa cara untuk codegen ini sih. Bisa lewat scan JPA entity juga kalau kita pakai JPA. Tapi pada kasus ini gw bikin lebih simple aja pakai script schema.sql. Kita juga bisa menggunakan ini lewat spring-boot-starter-jooq kalau modul kita menggunakan Spring Boot sehingga kita bisa reuse setup koneksi dari Spring Boot dan tinggal injek DSLContext lewat Spring Bean karena objeknya sudah dibuatin sama Spring Boot.
Standard Query & Filter Menggunakan jOOQ
Kita perlu setup dulu class entity dan repositorynya. Kita coba bikin yang sederhana dulu untuk melakukan query specific students dengan id antara 1-100, memiliki nama “ferry”, dan berstatus masih aktif.
@Getter
@Setter
@EqualsAndHashCode(of = "id")
public class StudentEntity{
private long id;
private String npm;
private String nik;
private String studentName;
private boolean active;
private LocalDate birthDate;
private Long studentBatchId;
private Integer version;
}public interface StudentRepository{
List<StudentEntity> findSpecificStudents();
}@RequiredArgsConstructor
public class StudentJooqRepository implements StudentRepository{
private final DSLContext dsl;
private static final Student STUDENT = Student.STUDENT;
@Override
public List<StudentEntity> findSpecificStudents(){
return dsl.select()
.from(STUDENT)
.where(STUDENT.IS_ACTIVE.isTrue()
.and(STUDENT.STUDENT_NAME.contains("ferry"))
.and(STUDENT.ID.between(1L, 100L)))
.fetchInto(StudentEntity.class);
}
}public static void main(String[] args){
StudentRepository studentRepository = new StudentJooqRepository(DSL.using("jdbc:postgresql://localhost:5432/dynamic_query?currentSchema=dynamic_query_example", "postgres", "password"));
List<StudentEntity> students = studentRepository.findSpecificStudents();
for(StudentEntity student : students){
System.out.println("student.getStudentName() = " + student.getStudentName());
}
}Constant Student.STUDENT itu adalah hasil generate source tadi. Kita menggunakan DSLContext dari jOOQ untuk interaksi dengan database. Kita menggunakan Java Bean sebagai Entity biar sederhana. Sebenarnya hasil query bisa ditampung di macam-macam class, kayak immutable menggunakan record, interface dengan getters/setters method, tapi kali ini kita menggunakan versi yang lebih simple aja pake Java Bean dengan select semua kolom. Penggunaannya mirip dengan syntax sql.
Dynamic Filter Menggunakan QueryDSL
Sekarang kita bikin versi dinamisnya😎.
@Builder
public record StudentFilter(String npm, Set<String> npms, String nik, String studentName, Boolean active,
LocalDate birthDateRangeStart, LocalDate birthDateRangeEnd, Integer batchYear){
}@RequiredArgsConstructor
public class StudentJooqRepository implements StudentRepository{
private final DSLContext dsl;
private static final Student STUDENT = Student.STUDENT;
private static final StudentBatch STUDENT_BATCH = StudentBatch.STUDENT_BATCH;
@Override
public List<StudentEntity> findByFilter(StudentFilter filter){
SelectJoinStep<Record> query = dsl.select()
.from(STUDENT);
if(filter.batchYear() != null){
query.innerJoin(STUDENT_BATCH)
.on(STUDENT_BATCH.ID.eq(STUDENT.STUDENT_BATCH_ID));
}
return query.where
.fetchInto(StudentEntity.class);
}
private Condition constructWhereClause(StudentFilter filter){
Condition condition = DSL.noCondition();
if(filter.active() != null){
condition = condition.and(STUDENT.IS_ACTIVE.eq(filter.active()));
}
if(filter.npm() != null){
condition = condition.and(STUDENT.NPM.contains(filter.npm()));
}
if(filter.npms() != null && !filter.npms().isEmpty()){
condition = condition.and(STUDENT.NPM.in(filter.npms()));
}
if(filter.studentName() != null){
condition = condition.and(DSL.lower(STUDENT.STUDENT_NAME).contains(filter.studentName().toLowerCase()));
}
if(filter.nik() != null){
condition = condition.and(STUDENT.NIK.eq(filter.nik()));
}
if(filter.birthDateRangeStart() != null && filter.birthDateRangeEnd() != null){
condition = condition.and(STUDENT.BIRTH_DATE.between(filter.birthDateRangeStart(), filter.birthDateRangeEnd()));
}
if(filter.batchYear() != null){
condition = condition.and(STUDENT_BATCH.BATCH_YEAR.eq(filter.batchYear()));
}
return condition;
}
}Codenya mirip-mirip QueryDSL. Di jOOQ utilitiesnya immutable dan ga ada builder, jadi di tiap ada mutasi seperti di Condition harus diassign ulang. Dynamic Joinnya juga lebih enak dibaca dibanding JPA specification. Kita atur agar join ke tabel student_batch hanya ketika ada filter terkait ke tabel itu seperti filter menggunakan batch_year. Jadi selain itu ga perlu join.
Dynamic Selection Menggunakan Projection
Di sini kita juga bisa bikin custom projection agar hanya kolom tertentu yang kita ambil.
public record StudentNameAndBirthDateProjection(String studentName, LocalDate birthDate){
}@RequiredArgsConstructor
public class StudentJooqRepository implements StudentRepository{
private final DSLContext dsl;
private static final Student STUDENT = Student.STUDENT;
private static final StudentBatch STUDENT_BATCH = StudentBatch.STUDENT_BATCH;
private static final Map<Class<?>, Collection<TableField<?, ?>>> SELECTION_BY_PROJECTION = Map.ofEntries(
Map.entry(StudentNameAndBirthDateProjection.class, List.of(STUDENT.STUDENT_NAME, STUDENT.BIRTH_DATE))
);
@Override
public <C> List<C> findByFilterProjections(StudentFilter filter, Class<C> clazz){
SelectJoinStep<Record> query = dsl.select(SELECTION_BY_PROJECTION.get(clazz))
.from(STUDENT);
if(filter.batchYear() != null){
query = query.innerJoin(STUDENT_BATCH)
.on(STUDENT_BATCH.ID.eq(STUDENT.STUDENT_BATCH_ID));
}
return query.where(constructWhereClause(filter))
.fetchInto(clazz);
}
}Kekurangannya ini ga otomatis mappingnya seperti Spring Data JPA. Di sini kita harus mapping sendiri antara projection class yang akan dipakai dan selectionnya apa aja lewat Map. Perlu diperhatikan juga, urutan properti di constructornya harus sama dengan urutan selection di Collection karena apa yang di-mapping di projection harus sesuai urutannya dengan di selection. Tiap ada projection baru harus dimapping dulu di sini.
Advanced Dynamic Selection dan Dynamic Join
Sebelumnya kita hanya join ke student_batch jika ada filter ke tabel itu. Sekarang kita juga ingin join ke sana hanya saat ada selection ke tabel itu atau ada filter ke tabel itu.
public record StudentNameAndBatchNameProjection(String studentName, String batchName){
}@RequiredArgsConstructor
public class StudentJooqRepository implements StudentRepository{
private final DSLContext dsl;
private static final Student STUDENT = Student.STUDENT;
private static final StudentBatch STUDENT_BATCH = StudentBatch.STUDENT_BATCH;
private static final Map<Class<?>, Collection<TableField<?, ?>>> SELECTION_BY_PROJECTION = Map.ofEntries(
Map.entry(StudentNameAndBirthDateProjection.class, List.of(STUDENT.STUDENT_NAME, STUDENT.BIRTH_DATE)),
Map.entry(StudentNameAndBatchNameProjection.class, List.of(STUDENT.STUDENT_NAME, STUDENT_BATCH.BATCH_NAME))
);
@Override
public <C> List<C> findByAdvancedFilterProjections(StudentFilter filter, Class<C> clazz){
SelectJoinStep<Record> query = dsl.select(SELECTION_BY_PROJECTION.get(clazz))
.from(STUDENT);
if(filter.batchYear() != null || containsBatchSelection(clazz)){
query.innerJoin(STUDENT_BATCH)
.on(STUDENT_BATCH.ID.eq(STUDENT.STUDENT_BATCH_ID));
}
SelectConditionStep<Record> finalQuery = query.where(constructWhereClause(filter));
System.out.println("finalQuery.getSQL() = " + finalQuery.getSQL());
return finalQuery
.fetchInto(clazz);
}
private static <C> boolean containsBatchSelection(Class<C> clazz){
return SELECTION_BY_PROJECTION.getOrDefault(clazz, List.of())
.stream()
.anyMatch(field -> STUDENT_BATCH.equals(field.getTable()));
}
}Kita menambahkan projection StudentNameAndBatchNameProjection pada constant mappingnya sebagai contoh. Kalau kita menggunakan projection yang tabelnya dari student_batch saat query maka dia akan join ke sana. Untuk membuktikannya kita bisa cek lewat print method getSQL() sebelum fetch data.
Verdict
jOOQ adalah alternatif dari QueryDSL dan Spring Data JPA untuk melakukan query secara dinamis. Secara fitur kurang lebih mirip dengan QueryDSL. Bedanya QueryDSL sekarang sudah mati suri dan lama ga update. Jadi jOOQ ini jadi alternatif yang lebih bisa diandalkan sekarang. jOOQ ini juga compatible dengan Spring Boot sehingga kalau kita udah pakai Spring Boot kita bisa integrasi dengan mudah.
