![Dynamic Query pada Java, Part II: QueryDSL](https://images.ctfassets.net/7wjlcr9d5ii8/2cLNK2jtSqsKOZPdC1y0uX/aa3c846b064214914e3886f819b42664/querydsl.png?w=1162&h=478&q=100&fm=png&fit=thumb&f=faces)
Lanjutan seri tentang Dynamic Query kali ini gw menggunakan library pihak ketiga. Seperti yang sudah dibahas sebelumnya, kita bisa menggunakan library pihak ketiga untuk melakukan dynamic query. Salah satu yang cukup populer adalah QueryDSL. Ini sebenarnya add-ons saja. Dia bisa men-generate query menggunakan native query, JPQL, atau specific bahasa tertentu tergantung dependency mana yang kita gunakan. Jadi misalkan kita menggunakan QueryDSL khusus JPA, maka behind the scene sebenarnya kita tetap melakukan query menggunakan JPQL. Bedanya, QueryDSL memiliki builder method untuk menghasilkan JPQL yang akan dieksekusi, sedangkan native Query menggunakan JPA atau Specification API-nya kurang user-friendly. Sebagai contoh kasus, kita tetap menggunakan use case Student seperti tulisan sebelumnya. Bagi yang belum tau atau lupa bisa cek tulisan tentang Dynamic Query menggunakan JPA.
Kita tetap menggunakan dependencies persis seperti tulisan sebelumnya, hanya saja kita menambahkan dependency & plugin QueryDSL di dalamnya.
Maven dependencies pom.xml
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-jpa</artifactId>
<version>5.0.0</version>
</dependency>
<build>
<plugins>
<plugin>
<groupId>org.bsc.maven</groupId>
<artifactId>maven-processor-plugin</artifactId>
<version>4.5</version>
<executions>
<execution>
<id>process</id>
<goals>
<goal>process</goal>
</goals>
<phase>generate-sources</phase>
<configuration>
<outputDirectory>target/generated-sources/java</outputDirectory>
<processors>
<processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
</processors>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.querydsl</groupId>
<artifactId>querydsl-apt</artifactId>
<version>5.0.0</version>
</dependency>
</dependencies>
</plugin>
</plugins>
</build>
Pada pom.xml diatas, kita juga menambahkan maven-processor-plugin untuk melakukan generate class mirip seperti hibernate-jpamodelgen. Jadi nanti semua class entity akan dibuatkan class dengan prefix Q
oleh QueryDSL yang menyimpan constant yang diperlukan, seperti QStudent
. Setelah menambahkan dependency dan plugin di atas, kita harus eksekusi mvn clean install
atau mvn generate-sources
di command line agar class dengan prefix Q
tersebut di-generate. Atau kalau menggunakan Intellij bisa dengan cara klik kanan folder module yang digunakan -> pilih maven -> pilih generate sources and update folders. Pastikan tidak ada compile error di dalam module, kalau ada maka class tersebut akan gagal digenerate dan compiler akan memberikan false error seperti "Symbol not found".
Sebelum memulai, kita perlu setup dulu repository-nya. Kita bisa reuse repository dari JPA sebelumnya dan extend custom repository baru yang nanti kita buat. Untuk membuat custom repository menggunakan Spring Data JPA dibutuhkan implementasi repository dengan nama yang mirip dengan custom respositoy tersebut ditambah suffix "Impl". Contohnya interface "StudentDslRepository" implementasinya "StudentDslRepositoryImpl". Kita coba bikin satu method dulu, yaitu untuk melakukan query specific students dengan id antara 1-100, memiliki nama “ferry”, dan berstatus masih aktif.
Interface StudentDslRepository
public interface StudentDslRepository{
List<Student> findSpecificStudents();
}
Class StudentDslRepositoryImpl
import static app.netlify.ferry.example.dynamic_query.persistence.QStudent.student;
import static app.netlify.ferry.example.dynamic_query.persistence.QStudentBatch.studentBatch;
@RequiredArgsConstructor
public class StudentDslRepositoryImpl implements StudentDslRepository{
private final EntityManager entityManager;
@Override
public List<Student> findSpecificStudents(){
JPQLQueryFactory jpqlQueryFactory = new JPAQueryFactory(entityManager);
return jpqlQueryFactory.selectFrom(student)
.where(student.active.isTrue()
.and(student.studentName.contains("ferry"))
.and(student.id.between(1, 100)))
.fetch();
}
}
Interface StudentSpringJpaRepository
public interface StudentSpringJpaRepository extends JpaRepository<Student, Long>, StudentDslRepository{ }
Kita membutuhkan JPA Entity Manager disini, karena seperti yang udah di-mention di atas, behind the scene kita tetap menggunakan JPA. Bisa dilihat nanti di query yang di-print di console kalau opsi show sql-nya aktif di config. Cukup gunakan constant dari class yang di-generate seperti QStudent di atas tanpa perlu repot-repot bikin constant sendiri secara manual. Biar nanti kita ga perlu memanggil ulang Class QStudent dan generated "Q" class lainnya, kita bisa import static constant tersebut. Sampai disini gw rasa udah cukup jelas code-nya.
Sekarang kita bikin versi dinamisnya😎. Kita masih menggunakan StudentFilter seperti sebelumnya sebagai parameter.
Class StudentDslRepositoryImpl
@RequiredArgsConstructor
public class StudentDslRepositoryImpl implements StudentDslRepository{
private final EntityManager entityManager;
@Override
public List<Student> findByFilter(StudentFilter filter){
JPQLQueryFactory jpqlQueryFactory = new JPAQueryFactory(entityManager);
JPQLQuery<Student> query = jpqlQueryFactory.selectFrom(student);
if(filter.getBatchYear() != null){
query.innerJoin(studentBatch)
.on(studentBatch.id.eq(student.studentBatchId));
}
query.where(constructWhereClause(filter));
return query.fetch();
}
private Predicate constructWhereClause(StudentFilter filter){
BooleanBuilder builder = new BooleanBuilder();
if(filter.getActive() != null){
builder.and(student.active.eq(filter.getActive()));
}
if(filter.getNpm() != null){
builder.and(student.npm.contains(filter.getNpm()));
}
if(filter.getStudentName() != null){
builder.and(student.studentName.lower().contains(filter.getStudentName().toLowerCase()));
}
if(filter.getNik() != null){
builder.and(student.nik.eq(filter.getNik()));
}
if(filter.getBirthDateRangeStart() != null && filter.getBirthDateRangeEnd() != null){
builder.and(student.birthDate.between(filter.getBirthDateRangeStart(), filter.getBirthDateRangeEnd()));
}
if(filter.getBatchYear() != null){
builder.and(studentBatch.batchYear.eq(filter.getBatchYear()));
}
return builder.getValue();
}
}
QueryDSL menyediakan BooleanBuilder untuk membuat where clause. Method-nya juga fluent dan cukup menjelaskan fungsinya, jadi lebih enak dibaca. Ga serepot menggunakan JPA Specification sebelumnya. Penggunaan Dynamic Join disini juga ga ribet karena ga dependant terhadap filternya, sehingga bisa kita pisah code-nya sesuai responsibility-nya.
Dengan QueryDSL kita juga bisa menggunakan Projection sama seperti Spring Data JPA, hanya saja bentuknya sangat berbeda.
Class StudentNameAndBirthDateProjection
@Value
public class StudentNameAndBirthDateProjection{
String studentName;
LocalDate birthDate;
}
Class StudentDslRepositoryImpl
@RequiredArgsConstructor
public class StudentDslRepositoryImpl implements StudentDslRepository{
private final EntityManager entityManager;
private final Map<Class<?>, Expression<?>> selectionByProjection = Map.ofEntries(
getConstructorEntry(StudentNameAndBirthDateProjection.class, student.studentName, student.birthDate)
);
private static Map.Entry<Class<?>, Expression<?>> getConstructorEntry(Class<?> clazz, Expression<?>... expressions){
return Map.entry(clazz, Projections.constructor(clazz, expressions));
}
@Override
public <C> List<C> findByFilterProjections(StudentFilter filter, Class<C> clazz){
JPQLQuery<C> query = new JPAQuery<>(entityManager);
query.select(selectionByProjection.get(clazz))
.from(student);
if(filter.getBatchYear() != null){
query.innerJoin(studentBatch)
.on(studentBatch.id.eq(student.studentBatchId));
}
query.where(constructWhereClause(filter));
return query.fetch();
}
}
Kita membuat mapping array selection column dengan class projection yang sesuai. Lalu class dan array selection column tersebut kita gunakan sebagai argumen yang dibungkus class Projections dari queryDSL. Sebenarnya ada beberapa cara untuk melakukan projection menggunakan QueryDSL, tapi gw lebih prefer menggunakan constructor karena immutable. I love Immutable Objects😊. Untuk cara selain constructor kalian bisa riset sendiri deh. Kalau menggunakan constructor, syaratnya harus mempunyai all arguments constructor, atau pada contoh di atas gw menggunakan annotasi @Value
dari Lombok. Lalu urutan arguments constructor tersebut harus sama dengan urutan selection column yang diinginkan, karena gw pakai Lombok maka urutan arguments constructor-nya mengikuti urutan field yang ditulis pada class. Contohnya pada class StudentNameAndBirthDateProjection urutan arguments-nya adalah studentName lalu birthDate, maka array selection column-nya berarti juga harus sama persis urutannya, yaitu studentName dulu baru setelah itu birthDate. Sekilas memang kelihatan ribet, tapi sejatinya dengan QueryDSL kita bisa mempersatukan Dynamic Filter dengan Dynamic Selection dan Dynamic Join, berbeda dengan JPA Specification atau @Query
pada Spring Data JPA. Kekurangannya tentu saja projection disini tidak di-generate otomatis secara runtime seperti Spring Data JPA dimana kita tinggal bikin interface dengan getter methods saja, melainkan kita tentukan sendiri selection column beserta class projection pada arguments-nya. Tapi menurut gw ini masih lebih user-friendly, lebih gampang di-maintain dan dibaca dibanding Spring Data JPA dan Specification. Hanya saja ada effort lebih untuk bikin masing-masing projectionnya.
Langsung ke tahap berikutnya, yaitu join clause yang dinamis berdasarkan selection clause. Sebelumnya dynamic join itu berdasarkan filter, nah sekarang kita bikin berdasarkan selection. Misalkan kalau kita ingin bikin projection terhadap column “studentName” dari table “student” dengan column “batchName” dari table “studentBatch”, maka kita butuh setup mapping antara selection dan tablenya juga.
Class StudentNameAndBatchNameProjection
@Value
public class StudentNameAndBatchNameProjection{
String studentName;
String batchName;
}
Class StudentDslRepositoryImpl
import static app.netlify.ferry.example.dynamic_query.persistence.QStudent.student;
import static app.netlify.ferry.example.dynamic_query.persistence.QStudentBatch.studentBatch;
@RequiredArgsConstructor
public class StudentDslRepositoryImpl implements StudentDslRepository{
private final EntityManager entityManager;
private final Map<Class<?>, Expression<?>> selectionByProjection = Map.ofEntries(
getConstructorEntry(StudentNameAndBirthDateProjection.class, student.studentName, student.birthDate),
getConstructorEntry(StudentNameAndBatchNameProjection.class, student.studentName, studentBatch.batchName)
);
private final Map<Class<?>, Set<EntityPathBase<?>>> entityPathByProjection = Map.of(
StudentNameAndBatchNameProjection.class, Set.of(studentBatch)
);
@Override
public <C> List<C> findByAdvancedFilterProjections(StudentFilter filter, Class<C> clazz){
JPQLQuery<C> query = new JPAQuery<>(entityManager);
query.select(selectionByProjection.get(clazz))
.from(student);
Set<EntityPathBase<?>> joinPaths = entityPathByProjection.getOrDefault(clazz, Set.of());
if(filter.getBatchYear() != null || joinPaths.contains(studentBatch)){
query.innerJoin(studentBatch)
.on(studentBatch.id.eq(student.studentBatchId));
}
query.where(constructWhereClause(filter));
return query.fetch();
}
}
Contoh penggunaan
StudentFilter studentFilter = StudentFilter.builder()
.active(true)
.studentName("s")
.nik("1313727230300101")
.birthDateRangeEnd(LocalDate.now())
.birthDateRangeStart(LocalDate.of(2019, Month.JANUARY, 1))
.npm("1133080")
.batchYear(2013)
.build();
List<StudentNameAndBatchNameProjection> projections = studentRepository.findByAdvancedFilterProjections(studentFilter, StudentNameAndBatchNameProjection.class);
for(StudentNameAndBatchNameProjection projection : projections){
System.out.println("projection.getBatchName() = " + projection.getBatchName());
System.out.println("projection.getStudentName() = " + projection.getStudentName());
}
Kita perlu bikin mapping antara path table yang bakal di-join dengan jenis class projection yang bakal membutuhkan join pada table lain. Mapping-nya kita bikin dalam bentuk Set, karena ada kemungkinan projection tersebut membutuhkan join lebih dari satu class. Lalu tinggal validasi aja path table-nya sebelum join, kalau class projectionnya punya joinPaths maka akan di-join dengan table tersebut, selain itu berarti tidak perlu join. Kali ini sedikit kompleks ya😁. Tapi bagaimanapun juga, menurut gw ini cukup maintainable sih. Setiap ada perubahan nantinya tinggal adjust saja, entah itu projection, filter, atau joinnya. Untuk pendekatan yang lebih baik, mapping hal-hal di atas bisa diganti menggunakan enum, tapi untuk memperpendek tulisan ga gw kasih contoh disini, silakan eksperimen sendiri aja😉.
Kita telah mengeksploitasi penggunaan dynamic query menggunakan QueryDSL, mulai dari hal sederhana hingga hal paling kompleks. Secara fitur, QueryDSL lebih unggul untuk urusan dynamic query dibanding pure JPA. Termasuk dari sisi readability hingga maintanability, gw lebih prefer seperti ini dibanding Specification. Dengan memadukan JPA dan QueryDSL semuanya jadi lebih readable dan maintanable. Untuk source code-nya bisa cek di github. Sayangnya, QueryDSL ini udah ga dimaintain sama foundernya dan komunitasnya sekarang mulai redup, belum ada regenerasi. Library-nya masih update, tapi makin jarang. Terakhir rilis hingga tulisan ini dibuat adalah versi 5.0 yang dirilis pada July 2021 lalu. Tentu saja ini bikin khawatir penggunanya terhadap masa depan library ini meskipun dari segi fitur ini udah cukup bagus menurut gw. Oleh karena itu, ada alternative library lain, yaitu jOOQ. Kapan-kapan kalau ada waktu luang akan gw post tulisan tentang jOOQ. Oh ya, meskipun sekarang jadi serba flexible, bukan berarti semua use case pakai dynamic query yang sama ya. Justru itu bikin tightly-coupled ntar, bikin gampang senggol-senggolan sama use case lain. Tetap gunakan dynamic query dengan bijak, misalnya untuk satu use case doang yang kebetulan selection dan filteringnya dinamis. Kalau udah beda use case, better bikin method atau class baru aja.