Repo in-memory di Bagian 2 hilang saat server dimulai ulang. Pada Bagian 3 kita menggantinya dengan database SQLite via JDBC. SQLite dipilih karena tidak membutuhkan server terpisah; database disimpan dalam satu file atau bahkan di memori untuk pengujian. Sekalian, kita menambahkan validasi request yang lebih ketat sebelum data masuk ke database.
SQLite via JDBC #
Java Database Connectivity (JDBC) adalah API standar Java untuk mengakses database. SQLite menyediakan driver JDBC yang ringan; satu file JAR cukup. Untuk belajar, kita memakai database in-memory (:memory:) yang tidak meninggalkan file di sistem.
Implementasi Repo Database #
Kode berikut menunjukkan class RepoTugasDb yang mengakses SQLite:
// Bagian 3: Database dan validasi request.
// Mendemonstrasikan akses database SQLite via JDBC (in-memory) dan
// validasi request sebelum menyimpan.
import java.sql.Connection
import java.sql.DriverManager
import java.sql.PreparedStatement
data class Tugas(
val id: Int,
val judul: String,
val selesai: Boolean = false
)
object Validator {
fun validasiJudul(judul: String): Result<String> {
if (judul.isBlank()) return Result.failure(IllegalArgumentException("Judul tidak boleh kosong"))
if (judul.length > 200) return Result.failure(IllegalArgumentException("Judul maksimal 200 karakter"))
return Result.success(judul.trim())
}
}
class RepoTugasDb(private val conn: Connection) {
init {
conn.createStatement().use { stmt ->
stmt.executeUpdate("""
CREATE TABLE IF NOT EXISTS tugas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
selesai INTEGER NOT NULL DEFAULT 0
)
""".trimIndent())
}
}
fun semua(): List<Tugas> {
val hasil = mutableListOf<Tugas>()
conn.prepareStatement("SELECT id, judul, selesai FROM tugas ORDER BY id").use { ps ->
ps.executeQuery().use { rs ->
while (rs.next()) {
hasil.add(Tugas(rs.getInt("id"), rs.getString("judul"), rs.getInt("selesai") == 1))
}
}
}
return hasil
}
fun cari(id: Int): Tugas? {
conn.prepareStatement("SELECT id, judul, selesai FROM tugas WHERE id = ?").use { ps ->
ps.setInt(1, id)
ps.executeQuery().use { rs ->
if (rs.next()) return Tugas(rs.getInt("id"), rs.getString("judul"), rs.getInt("selesai") == 1)
}
}
return null
}
fun tambah(judul: String): Tugas {
val hasil = Validator.validasiJudul(judul).getOrElse {
throw IllegalArgumentException(it.message)
}
val keys = arrayOf("id")
conn.prepareStatement("INSERT INTO tugas (judul) VALUES (?)", keys).use { ps ->
ps.setString(1, hasil)
ps.executeUpdate()
val generatedKeys = ps.generatedKeys
if (generatedKeys.next()) {
val id = generatedKeys.getInt(1)
return Tugas(id, hasil, false)
}
}
throw IllegalStateException("Gagal mendapatkan ID")
}
fun tandaiSelesai(id: Int): Boolean {
conn.prepareStatement("UPDATE tugas SET selesai = 1 WHERE id = ?").use { ps ->
ps.setInt(1, id)
return ps.executeUpdate() > 0
}
}
fun hapus(id: Int): Boolean {
conn.prepareStatement("DELETE FROM tugas WHERE id = ?").use { ps ->
ps.setInt(1, id)
return ps.executeUpdate() > 0
}
}
}
fun main() {
// SQLite in-memory: tidak meninggalkan file
val conn = DriverManager.getConnection("jdbc:sqlite::memory:")
val repo = RepoTugasDb(conn)
println("=== Validasi judul ===")
val daftar = listOf("Belajar SQLite", " ", "x".repeat(250))
daftar.forEach { j ->
val r = Validator.validasiJudul(j)
println("Judul \"${j.take(20)}${if (j.length > 20) "..." else ""}\": ${
r.fold(onSuccess = { "valid" }, onFailure = { it.message })
}")
}
println()
println("=== Operasi database ===")
val t1 = repo.tambah("Belajar SQLite")
val t2 = repo.tambah("Bangun REST API")
println("Dibuat: ${t1.id}=${t1.judul}, ${t2.id}=${t2.judul}")
println()
println("=== Semua tugas ===")
repo.semua().forEach { println("[${it.id}] ${it.judul} selesai=${it.selesai}") }
println()
println("=== Tandai selesai t1 ===")
val sukses = repo.tandaiSelesai(t1.id)
println("Berhasil: $sukses")
println("Tugas t1 sekarang: ${repo.cari(t1.id)}")
println()
println("=== Hapus t2 ===")
val hapus = repo.hapus(t2.id)
println("Berhasil: $hapus")
println("Sisa tugas: ${repo.semua().size}")
println()
println("=== Coba tambah judul kosong ===")
try {
repo.tambah(" ")
} catch (e: IllegalArgumentException) {
println("Galat: ${e.message}")
}
conn.close()
}Output yang Dihasilkan:
=== Validasi judul ===
Judul "Belajar SQLite": valid
Judul " ": Judul tidak boleh kosong
Judul "xxxxxxxxxxxxxxxxxxxx...": Judul maksimal 200 karakter
=== Operasi database ===
Dibuat: 1=Belajar SQLite, 2=Bangun REST API
=== Semua tugas ===
[1] Belajar SQLite selesai=false
[2] Bangun REST API selesai=false
=== Tandai selesai t1 ===
Berhasil: true
Tugas t1 sekarang: Tugas(id=1, judul=Belajar SQLite, selesai=true)
=== Hapus t2 ===
Berhasil: true
Sisa tugas: 1
=== Coba tambah judul kosong ===
Galat: Judul tidak boleh kosongMari kita bedah operasi JDBC:
DriverManager.getConnection("jdbc:sqlite::memory:"): membuka koneksi ke database SQLite in-memory. URL:memory:membuat database baru di RAM; tidak ada file yang ditulis. Untuk produksi, ganti dengan path file sepertijdbc:sqlite:tugas.db.conn.createStatement().use { ... }:useadalah extension function yang otomatis menutup resource setelah blok selesai, miriptry-with-resourcesdi Java.CREATE TABLE IF NOT EXISTS: membuat tabel hanya jika belum ada. Aman dipanggil berulang; tidak menghapus data yang sudah ada.INTEGER PRIMARY KEY AUTOINCREMENT: kolom ID otomatis bertambah. SQLite menangani increment secara internal.conn.prepareStatement("... WHERE id = ?"): parameterized query. Tanda?diisi denganps.setInt(1, id). Mencegah SQL injection karena nilai tidak diinterpolasi ke string SQL.ps.executeUpdate() > 0: mengembalikan jumlah baris yang terpengaruh. Jika lebih besar dari nol, operasi berhasil; jika nol, tidak ada baris yang cocok (misalnya ID tidak ditemukan).
Validasi Sebelum Database #
RepoTugasDb.tambah(judul) memvalidasi judul sebelum INSERT. Jika validasi gagal, exception dilempar dan tidak ada perubahan di database. Pola ini menjaga database tetap konsisten: data yang masuk selalu memenuhi aturan.
fun tambah(judul: String): Tugas {
val hasil = Validator.validasiJudul(judul).getOrElse {
throw IllegalArgumentException(it.message)
}
// ... INSERT ke database
}Mengapa SQLite #
SQLite cocok untuk proyek menengah karena:
- Tanpa server: database adalah satu file, tidak butuh proses terpisah.
- Nol konfigurasi: driver JDBC satu JAR, tanpa instalasi.
- Standar SQL: mendukung sebagian besar SQL standar.
- Untuk belajar: in-memory mode memungkinkan test cepat tanpa file.
Untuk produksi dengan banyak koneksi concurrent atau data besar, PostgreSQL atau MySQL lebih cocok. Pada section Database & ORM, kita akan membahas migrasi tersebut.
Boolean sebagai INTEGER #
SQLite tidak punya tipe Boolean native; kita menyimpan 0 untuk false dan 1 untuk true. Konversi dilakukan dengan rs.getInt("selesai") == 1 saat membaca dan if (selesai) 1 else 0 saat menulis. PostgreSQL dan MySQL memiliki tipe BOOLEAN native, sehingga konversi ini tidak diperlukan.
Referensi Lanjutan #
- Ktor content negotiation
— panduan
ContentNegotiationuntuk menyimpan dan memuat entitas tugas sebagai JSON - Ktor serialization
— panduan
kotlinx.serializationuntuk memetakan DTO ke model database
Ringkasan Bagian 3 #
Database SQLite kini menyimpan tugas secara persisten. RepoTugasDb menangani operasi CRUD via JDBC dengan parameterized query, dan Validator memvalidasi input sebelum data masuk. SQLite in-memory cocok untuk pengujian; file mode cocok untuk produksi kecil. Pada Bagian 4 kita menambahkan authentication JWT untuk melindungi endpoint dan menulis test untuk seluruh API.