Mengakses basis data lewat JDBC murni berarti menulis pola yang sama berulang-ulang: buka koneksi, siapkan PreparedStatement, petakan ResultSet ke objek, tutup resource, dan tangani SQLException di setiap method. Spring Data JPA memotong seluruh pola itu dengan satu gagasan berani: kita cukup mendeklarasikan method di interface — implementasinya dihasilkan saat runtime. Pada Chapter ini kita kenal arsitektur berlapis di baliknya, keluarga Repository yang bisa dipilih, dan fitur paling terkenal: derived query dari nama method.

Arsitektur Berlapis #

Spring Data JPA bukan pengganti Hibernate — keduanya bekerja di lapisan berbeda. Hibernate adalah implementasi JPA (jakarta.persistence): ORM yang memetakan objek ke tabel dan sebaliknya. Spring Data JPA berdiri di atasnya sebagai lapisan abstraksi repository: menyerap pembuatan query, pagination, dan transaction management yang tadinya menjadi tugas kode kita. Peta lapisannya:

text
Controller -> Service -> Repository (Spring Data) -> EntityManager (JPA) -> Hibernate -> JDBC -> Database

Fondasi abstraksinya ada di Spring Data Commons: interface Repository<T, ID> sebagai akar, CrudRepository untuk operasi CRUD dasar, PagingAndSortingRepository untuk akses berhalaman, dan JpaRepository yang menambahkan utilitas spesifik JPA seperti flush() dan saveAll(). Spring Data menyediakan keluarga serupa untuk teknologi lain — MongoRepository, RedisRepository, JdbcRepository — dengan semangat yang sama.

Interface Repository dan Derived Query #

Seluruh prosesnya dimulai dari interface kosong yang kita deklarasikan sendiri — ditampilkan apa adanya untuk Spring Boot 3.4/3.5:

java
public interface ProdukRepository extends JpaRepository<Produk, Long> {

    List<Produk> findByKategori(String kategori);

    List<Produk> findByHargaLessThan(BigDecimal batas);

    Optional<Produk> findBySku(String sku);

    long countByKategori(String kategori);

    boolean existsBySku(String sku);

    @Query("SELECT p FROM Produk p WHERE p.nama LIKE %:kataKunci%")
    List<Produk> cariBerdasarkanNama(@Param("kataKunci") String kataKunci);
}

Tidak ada satu baris implementasi. Saat startup, Spring Data membuat proxy untuk setiap interface repository; method tanpa @Query diurai dari namanya menjadi JPQL, method bertanda @Query memakai query yang tertulis eksplisit. Konvensi penamaan pengurai: prefiks (findBy, countBy, existsBy, deleteBy) menentukan operasi, potongan setelah By menjadi kriteria, kata And/Or menyambung kriteria, OrderBy...Desc mengatur urutan.

Mensimulasikan Pengurai Nama Method #

Alat penguraian nama method layak dibedah agar konvensinya tidak terasa seperti tebakan. Program Java murni berikut mengurai lima nama method repository menjadi operasi, kriteria, dan urutan:

import java.util.ArrayList;
import java.util.List;

public class TurunanQuery {

    // Hasil penguraian nama method repository ala Spring Data.
    record QueryTurunan(String operasi, List<String> kriteria, String penyambung,
                        String kolomUrut, boolean menurun) {

        String keSql() {
            StringBuilder sql = new StringBuilder();
            sql.append(switch (operasi) {
                case "find" -> "SELECT * FROM tabel";
                case "count" -> "SELECT COUNT(*) FROM tabel";
                case "exists" -> "SELECT EXISTS(SELECT 1 FROM tabel";
                case "delete" -> "DELETE FROM tabel";
                default -> throw new IllegalStateException();
            });
            if (!kriteria.isEmpty()) {
                sql.append(" WHERE ");
                for (int i = 0; i < kriteria.size(); i++) {
                    sql.append(i == 0 ? "" : " " + penyambung + " ");
                    sql.append(kriteria.get(i)).append(" = ?");
                }
            }
            if (kolomUrut != null) {
                sql.append(" ORDER BY ").append(kolomUrut)
                        .append(menurun ? " DESC" : " ASC");
            }
            if (operasi.equals("exists")) {
                sql.append(")");
            }
            return sql.toString();
        }
    }

    public static void main(String[] args) {
        String[] namaMethod = {
                "findByEmailAndStatus",
                "countByStatus",
                "existsByEmail",
                "deleteByKategori",
                "findByNamaOrderByTanggalDibuatDesc"
        };

        for (String nama : namaMethod) {
            QueryTurunan q = uraikan(nama);
            System.out.println(nama);
            System.out.println("  operasi  : " + q.operasi());
            System.out.println("  kriteria : " + q.kriteria()
                    + (q.kriteria().size() > 1 ? " (disambung " + q.penyambung() + ")" : ""));
            System.out.println("  urutan   : " + (q.kolomUrut() == null
                    ? "-" : q.kolomUrut() + (q.menurun() ? " DESC" : " ASC")));
            System.out.println("  kira-kira: " + q.keSql());
            System.out.println();
        }
    }

    static QueryTurunan uraikan(String nama) {
        String operasi;
        if (nama.startsWith("findBy")) {
            operasi = "find";
        } else if (nama.startsWith("countBy")) {
            operasi = "count";
        } else if (nama.startsWith("existsBy")) {
            operasi = "exists";
        } else if (nama.startsWith("deleteBy")) {
            operasi = "delete";
        } else {
            throw new IllegalArgumentException("Operasi tidak dikenal: " + nama);
        }

        // Buang prefiks operasi + "By".
        String sisa = nama.substring(operasi.length() + 2);

        // Pisahkan klausa OrderBy bila ada.
        String kolomUrut = null;
        boolean menurun = false;
        int idxOrder = sisa.indexOf("OrderBy");
        if (idxOrder >= 0) {
            String urutan = sisa.substring(idxOrder + "OrderBy".length());
            menurun = urutan.endsWith("Desc");
            kolomUrut = menurun
                    ? urutan.substring(0, urutan.length() - "Desc".length())
                    : urutan;
            sisa = sisa.substring(0, idxOrder);
        }

        // Pecah kriteria pada And / Or.
        List<String> kriteria = new ArrayList<>();
        String penyambung = "AND";
        if (sisa.contains("And")) {
            for (String k : sisa.split("And")) {
                kriteria.add(kecilAwal(k));
            }
        } else if (sisa.contains("Or")) {
            penyambung = "OR";
            for (String k : sisa.split("Or")) {
                kriteria.add(kecilAwal(k));
            }
        } else if (!sisa.isEmpty()) {
            kriteria.add(kecilAwal(sisa));
        }
        return new QueryTurunan(operasi, kriteria, penyambung,
                kolomUrut == null ? null : kecilAwal(kolomUrut), menurun);
    }

    // "EmailAndStatus" -> "email": huruf pertama kecil seperti nama property.
    static String kecilAwal(String s) {
        return Character.toLowerCase(s.charAt(0)) + s.substring(1);
    }
}

Output yang Dihasilkan:

text
findByEmailAndStatus
  operasi  : find
  kriteria : [email, status] (disambung AND)
  urutan   : -
  kira-kira: SELECT * FROM tabel WHERE email = ? AND status = ?

countByStatus
  operasi  : count
  kriteria : [status]
  urutan   : -
  kira-kira: SELECT COUNT(*) FROM tabel WHERE status = ?

existsByEmail
  operasi  : exists
  kriteria : [email]
  urutan   : -
  kira-kira: SELECT EXISTS(SELECT 1 FROM tabel WHERE email = ?)

deleteByKategori
  operasi  : delete
  kriteria : [kategori]
  urutan   : -
  kira-kira: DELETE FROM tabel WHERE kategori = ?

findByNamaOrderByTanggalDibuatDesc
  operasi  : find
  kriteria : [nama]
  urutan   : tanggalDibuat DESC
  kira-kira: SELECT * FROM tabel WHERE nama = ? ORDER BY tanggalDibuat DESC

Mari kita bedah pengurainya:

  1. Deretan startsWith untuk operasi: string diperiksa terhadap empat prefiks yang dikenal Spring Data. Sisa nama dipotong dengan substring(operasi.length() + 2) — angka 2 menghapus kata By yang selalu mengikuti prefiks.
  2. indexOf("OrderBy") sebelum pemecahan kriteria: urutan pengerjaan ini penting. Klausa OrderBy harus dipisahkan lebih dulu; jika tidak, pemecahan pada Or akan salah memotong kata OrderBy menjadi kriteria palsu — jebakan nyata pada pengurai sederhana.
  3. split("And") dan split("Or"): kriteria dipecah pada kata sambung, lalu kecilAwal() menurunkan huruf pertama (Email -> email) sehingga hasilnya cocok dengan nama property entity. Dua kriteria pada contoh pertama tercetak [email, status] dengan penyambung AND.
  4. keSql() dengan switch expression: hasil penguraian diterjemahkan menjadi SQL perkiraan agar mudah dibaca. Method findByNamaOrderByTanggalDibuatDesc menjadi WHERE nama = ? ORDER BY tanggalDibuat DESC — persis bagaimana Spring Data menyusun JPQL-nya sebelum Hibernate mengubahnya menjadi SQL asli.

Konfigurasi Koneksi #

Dua starter menutup seluruh kebutuhan: spring-boot-starter-data-jpa membawa Hibernate dan Spring Data, sementara driver basis data ditambahkan terpisah. Konfigurasi koneksi berjalan lewat application.properties — ditampilkan apa adanya:

properties
spring.datasource.url=jdbc:postgresql://db.example.com/produksi
spring.datasource.username=budi
spring.datasource.password=${DB_PASSWORD}
spring.jpa.hibernate.ddl-auto=validate
spring.jpa.open-in-view=false

Properti ddl-auto=validate menjaga Hibernate tidak mengubah skema di produksi — migrasi skema adalah tugas tool khusus seperti Flyway atau Liquibase. Properti open-in-view=false mematikan pola yang membiarkan lazy loading berjalan di lapisan web — pembahasan mendalamnya ada di Chapter Entities dan Repositories, tempat kita membedah pemetaan objek-tabel beserta jebakan relasinya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

TurunanQuery.java

Unduh berkas
java
import java.util.ArrayList;
import java.util.List;

public class TurunanQuery {

    // Hasil penguraian nama method repository ala Spring Data.
    record QueryTurunan(String operasi, List<String> kriteria, String penyambung,
                        String kolomUrut, boolean menurun) {

        String keSql() {
            StringBuilder sql = new StringBuilder();
            sql.append(switch (operasi) {
                case "find" -> "SELECT * FROM tabel";
                case "count" -> "SELECT COUNT(*) FROM tabel";
                case "exists" -> "SELECT EXISTS(SELECT 1 FROM tabel";
                case "delete" -> "DELETE FROM tabel";
                default -> throw new IllegalStateException();
            });
            if (!kriteria.isEmpty()) {
                sql.append(" WHERE ");
                for (int i = 0; i < kriteria.size(); i++) {
                    sql.append(i == 0 ? "" : " " + penyambung + " ");
                    sql.append(kriteria.get(i)).append(" = ?");
                }
            }
            if (kolomUrut != null) {
                sql.append(" ORDER BY ").append(kolomUrut)
                        .append(menurun ? " DESC" : " ASC");
            }
            if (operasi.equals("exists")) {
                sql.append(")");
            }
            return sql.toString();
        }
    }

    public static void main(String[] args) {
        String[] namaMethod = {
                "findByEmailAndStatus",
                "countByStatus",
                "existsByEmail",
                "deleteByKategori",
                "findByNamaOrderByTanggalDibuatDesc"
        };

        for (String nama : namaMethod) {
            QueryTurunan q = uraikan(nama);
            System.out.println(nama);
            System.out.println("  operasi  : " + q.operasi());
            System.out.println("  kriteria : " + q.kriteria()
                    + (q.kriteria().size() > 1 ? " (disambung " + q.penyambung() + ")" : ""));
            System.out.println("  urutan   : " + (q.kolomUrut() == null
                    ? "-" : q.kolomUrut() + (q.menurun() ? " DESC" : " ASC")));
            System.out.println("  kira-kira: " + q.keSql());
            System.out.println();
        }
    }

    static QueryTurunan uraikan(String nama) {
        String operasi;
        if (nama.startsWith("findBy")) {
            operasi = "find";
        } else if (nama.startsWith("countBy")) {
            operasi = "count";
        } else if (nama.startsWith("existsBy")) {
            operasi = "exists";
        } else if (nama.startsWith("deleteBy")) {
            operasi = "delete";
        } else {
            throw new IllegalArgumentException("Operasi tidak dikenal: " + nama);
        }

        // Buang prefiks operasi + "By".
        String sisa = nama.substring(operasi.length() + 2);

        // Pisahkan klausa OrderBy bila ada.
        String kolomUrut = null;
        boolean menurun = false;
        int idxOrder = sisa.indexOf("OrderBy");
        if (idxOrder >= 0) {
            String urutan = sisa.substring(idxOrder + "OrderBy".length());
            menurun = urutan.endsWith("Desc");
            kolomUrut = menurun
                    ? urutan.substring(0, urutan.length() - "Desc".length())
                    : urutan;
            sisa = sisa.substring(0, idxOrder);
        }

        // Pecah kriteria pada And / Or.
        List<String> kriteria = new ArrayList<>();
        String penyambung = "AND";
        if (sisa.contains("And")) {
            for (String k : sisa.split("And")) {
                kriteria.add(kecilAwal(k));
            }
        } else if (sisa.contains("Or")) {
            penyambung = "OR";
            for (String k : sisa.split("Or")) {
                kriteria.add(kecilAwal(k));
            }
        } else if (!sisa.isEmpty()) {
            kriteria.add(kecilAwal(sisa));
        }
        return new QueryTurunan(operasi, kriteria, penyambung,
                kolomUrut == null ? null : kecilAwal(kolomUrut), menurun);
    }

    // "EmailAndStatus" -> "email": huruf pertama kecil seperti nama property.
    static String kecilAwal(String s) {
        return Character.toLowerCase(s.charAt(0)) + s.substring(1);
    }
}