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:

kotlin
// 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:

text
=== 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 kosong

Mari kita bedah operasi JDBC:

  1. 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 seperti jdbc:sqlite:tugas.db.
  2. conn.createStatement().use { ... }: use adalah extension function yang otomatis menutup resource setelah blok selesai, mirip try-with-resources di Java.
  3. CREATE TABLE IF NOT EXISTS: membuat tabel hanya jika belum ada. Aman dipanggil berulang; tidak menghapus data yang sudah ada.
  4. INTEGER PRIMARY KEY AUTOINCREMENT: kolom ID otomatis bertambah. SQLite menangani increment secara internal.
  5. conn.prepareStatement("... WHERE id = ?"): parameterized query. Tanda ? diisi dengan ps.setInt(1, id). Mencegah SQL injection karena nilai tidak diinterpolasi ke string SQL.
  6. 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.

kotlin Validasi sebelum INSERT
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 #

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.