Aplikasi yang menyimpan data pengguna biasanya membutuhkan database lokal. Di proyek native, kita menulis kode database berbeda per platform: Room di Android, CoreData di iOS, Hibernate di JVM. KMP dengan SQLDelight atau Room (KMP support sejak versi 2.7) menyederhanakan: skema SQL ditulis sekali, driver database spesifik per platform. Pada chapter ini kita akan melihat arsitektur SQLDelight, pola driver di source set, dan demonstrasi tiga driver berbeda melayani satu repository.
SQLDelight vs Room KMP #
SQLDelight dari Cash App adalah library KMP paling matang untuk persistence. Library ini menghasilkan kode Kotlin dari file .sq yang berisi SQL statement—CREATE TABLE, INSERT, SELECT, UPDATE. Kode yang dihasilkan type-safe dan berjalan di semua target. Room KMP (sejak versi 2.7) mengikuti pola yang sama tetapi memakai anotasi @Database, @Entity, @Dao yang sudah dikenal pengembang Android.
| Aspek | SQLDelight | Room KMP |
|---|---|---|
| Sumber query | File .sq dengan SQL mentah | Anotasi @Query, @Insert |
| Bahasa schema | SQL | Kotlin class dengan anotasi |
| Stabilitas | Stable, banyak dipakai | Stabil sejak Room 2.7 |
| Driver | SqlDriver interface | RoomDatabase.Builder |
| Migration | Migration dengan SQL | Migration dengan SQL |
Keduanya menghasilkan kode yang sama secara semantik—SQL yang dijalankan engine SQLite per platform. Pilihan antara keduanya biasanya didasari preferensi terhadap SQL mentah versus anotasi Kotlin.
Arsitektur Driver #
Driver adalah abstraksi atas engine SQLite per platform. Di commonMain, kita mendefinisikan interface atau memakai SqlDriver dari library. Di source set spesifik, kita menyediakan driver konkret.
sourceSets {
val commonMain by getting {
dependencies {
implementation("app.cash.sqldelight:runtime:2.0.1")
}
}
val androidMain by getting {
dependencies {
implementation("app.cash.sqldelight:android-driver:2.0.1")
}
}
val iosMain by getting {
dependencies {
implementation("app.cash.sqldelight:native-driver:2.0.1")
}
}
val jvmMain by getting {
dependencies {
implementation("app.cash.sqldelight:sqlite-driver:2.0.1")
}
}
}import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
actual class DatabaseDriverFactory(private val context: Context) {
actual fun create(): SqlDriver {
return AndroidSqliteDriver(TugasDatabase.Schema, context, "tugas.db")
}
}import app.cash.sqldelight.db.SqlDriver
import app.cash.sqldelight.driver.native.NativeSqliteDriver
actual class DatabaseDriverFactory {
actual fun create(): SqlDriver {
return NativeSqliteDriver(TugasDatabase.Schema, "tugas.db")
}
}Simulasi Database Multiplatform #
Demo berikut mensimulasikan pola SQLDelight dengan interface DatabaseDriver dan tiga implementasi. Pada proyek nyata, kode ini dihasilkan otomatis dari file .sq oleh plugin SQLDelight.
// multiplatform_persistence.kt
// SQLDelight / Room KMP: SQL di commonMain, driver per platform.
// Demo: simulasi database sederhana dengan interface driver + 3 implementasi.
// --- commonMain: model data dan query interface ---
data class Tugas(
val id: Long,
val judul: String,
val selesai: Boolean,
val dibuatPada: String
)
interface DatabaseDriver {
fun buatTabel()
fun masukkanTugas(judul: String, dibuatPada: String): Long
fun ambilSemuaTugas(): List<Tugas>
fun tandaiSelesai(id: Long, selesai: Boolean): Boolean
fun hapusTugas(id: Long): Boolean
}
// --- commonMain: logika akses data ---
class RepositoriTugas(private val driver: DatabaseDriver) {
init {
driver.buatTabel()
}
fun tambah(judul: String, tanggal: String): Long {
require(judul.isNotBlank()) { "judul wajib" }
require(judul.length <= 100) { "judul maksimal 100 karakter" }
return driver.masukkanTugas(judul, tanggal)
}
fun daftar(): List<Tugas> {
val semua = driver.ambilSemuaTugas()
return semua.sortedBy { it.id }
}
fun tandaiSelesai(id: Long): Boolean = driver.tandaiSelesai(id, true)
fun ringkasan(): String {
val semua = driver.ambilSemuaTugas()
val selesai = semua.count { it.selesai }
val belum = semua.size - selesai
return "Total ${semua.size} tugas: $selesai selesai, $belum belum"
}
}
// --- Platform: SQLite driver (simulasi) ---
// Pada SQLDelight nyata, driver adalah:
// - Android: AndroidSqliteDriver
// - iOS, Native: NativeSqliteDriver
// - JVM: JdbcSqliteDriver (in-memory atau file)
class InMemoryDriver : DatabaseDriver {
private val tabel = mutableListOf<Tugas>()
private var counter = 0L
override fun buatTabel() {
tabel.clear()
counter = 0L
}
override fun masukkanTugas(judul: String, dibuatPada: String): Long {
val id = ++counter
tabel.add(Tugas(id, judul, selesai = false, dibuatPada))
return id
}
override fun ambilSemuaTugas(): List<Tugas> = tabel.toList()
override fun tandaiSelesai(id: Long, selesai: Boolean): Boolean {
val idx = tabel.indexOfFirst { it.id == id }
if (idx < 0) return false
tabel[idx] = tabel[idx].copy(selesai = selesai)
return true
}
override fun hapusTugas(id: Long): Boolean {
val idx = tabel.indexOfFirst { it.id == id }
if (idx < 0) return false
tabel.removeAt(idx)
return true
}
}
class AndroidSqliteDriverSimulasi(private val dbName: String) : DatabaseDriver {
// Di Android nyata: AndroidSqliteDriver(Schema, Context, dbName)
private val tabel = mutableListOf<Tugas>()
private var counter = 0L
override fun buatTabel() {
// CREATE TABLE IF NOT EXISTS tugas (id INTEGER PRIMARY KEY, ...)
tabel.clear()
counter = 0L
}
override fun masukkanTugas(judul: String, dibuatPada: String): Long {
val id = ++counter
tabel.add(Tugas(id, judul, selesai = false, dibuatPada))
return id
}
override fun ambilSemuaTugas(): List<Tugas> = tabel.toList()
override fun tandaiSelesai(id: Long, selesai: Boolean): Boolean {
val idx = tabel.indexOfFirst { it.id == id }
if (idx < 0) return false
tabel[idx] = tabel[idx].copy(selesai = selesai)
return true
}
override fun hapusTugas(id: Long): Boolean {
val idx = tabel.indexOfFirst { it.id == id }
if (idx < 0) return false
tabel.removeAt(idx)
return true
}
}
class NativeSqliteDriverSimulasi(private val dbName: String) : DatabaseDriver {
// Di iOS/Native nyata: NativeSqliteDriver(Schema, dbName)
private val tabel = mutableListOf<Tugas>()
private var counter = 0L
override fun buatTabel() {
tabel.clear()
counter = 0L
}
override fun masukkanTugas(judul: String, dibuatPada: String): Long {
val id = ++counter
tabel.add(Tugas(id, judul, selesai = false, dibuatPada))
return id
}
override fun ambilSemuaTugas(): List<Tugas> = tabel.toList()
override fun tandaiSelesai(id: Long, selesai: Boolean): Boolean {
val idx = tabel.indexOfFirst { it.id == id }
if (idx < 0) return false
tabel[idx] = tabel[idx].copy(selesai = selesai)
return true
}
override fun hapusTugas(id: Long): Boolean {
val idx = tabel.indexOfFirst { it.id == id }
if (idx < 0) return false
tabel.removeAt(idx)
return true
}
}
// --- Demo ---
fun main() {
println("=== Multiplatform persistence (SQLDelight simulasi) ===\n")
val drivers = listOf<Pair<String, DatabaseDriver>>(
"JVM" to InMemoryDriver(),
"Android" to AndroidSqliteDriverSimulasi("tugas.db"),
"iOS" to NativeSqliteDriverSimulasi("tugas.db")
)
for ((platform, driver) in drivers) {
println("--- Platform: $platform ---")
val repo = RepositoriTugas(driver)
val id1 = repo.tambah("Belajar KMP basics", "2026-01-15")
val id2 = repo.tambah("Tulis unit test", "2026-01-15")
val id3 = repo.tambah("Setup CI/CD", "2026-01-16")
repo.tandaiSelesai(id1)
println(" Tugas ID setelah insert: $id1, $id2, $id3")
println(" Daftar tugas:")
for (t in repo.daftar()) {
val status = if (t.selesai) "[x]" else "[ ]"
println(" $status #${t.id} ${t.judul} (dibuat ${t.dibuatPada})")
}
println(" Ringkasan: ${repo.ringkasan()}")
println()
}
println("=== Verifikasi SQL yang dijalankan (simulasi) ===")
println(" CREATE TABLE IF NOT EXISTS tugas (")
println(" id INTEGER PRIMARY KEY AUTOINCREMENT,")
println(" judul TEXT NOT NULL,")
println(" selesai INTEGER NOT NULL DEFAULT 0,")
println(" dibuat_pada TEXT NOT NULL")
println(" );")
println(" INSERT INTO tugas (judul, selesai, dibuat_pada) VALUES (?, ?, ?);")
println(" SELECT id, judul, selesai, dibuat_pada FROM tugas ORDER BY id;")
println(" UPDATE tugas SET selesai = ? WHERE id = ?;")
}Output yang Dihasilkan:
=== Multiplatform persistence (SQLDelight simulasi) ===
--- Platform: JVM ---
Tugas ID setelah insert: 1, 2, 3
Daftar tugas:
[x] #1 Belajar KMP basics (dibuat 2026-01-15)
[ ] #2 Tulis unit test (dibuat 2026-01-15)
[ ] #3 Setup CI/CD (dibuat 2026-01-16)
Ringkasan: Total 3 tugas: 1 selesai, 2 belum
--- Platform: Android ---
Tugas ID setelah insert: 1, 2, 3
Daftar tugas:
[x] #1 Belajar KMP basics (dibuat 2026-01-15)
[ ] #2 Tulis unit test (dibuat 2026-01-15)
[ ] #3 Setup CI/CD (dibuat 2026-01-16)
Ringkasan: Total 3 tugas: 1 selesai, 2 belum
--- Platform: iOS ---
Tugas ID setelah insert: 1, 2, 3
Daftar tugas:
[x] #1 Belajar KMP basics (dibuat 2026-01-15)
[ ] #2 Tulis unit test (dibuat 2026-01-15)
[ ] #3 Setup CI/CD (dibuat 2026-01-16)
Ringkasan: Total 3 tugas: 1 selesai, 2 belum
=== Verifikasi SQL yang dijalankan (simulasi) ===
CREATE TABLE IF NOT EXISTS tugas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
selesai INTEGER NOT NULL DEFAULT 0,
dibuat_pada TEXT NOT NULL
);
INSERT INTO tugas (judul, selesai, dibuat_pada) VALUES (?, ?, ?);
SELECT id, judul, selesai, dibuat_pada FROM tugas ORDER BY id;
UPDATE tugas SET selesai = ? WHERE id = ?;Mari kita bedah bagian kunci dari kode di atas:
data class Tugas: model data dicommonMain. Field-nya hanya tipe primitif (Long, String, Boolean) sehingga dapat dipetakan ke kolom SQLite.interface DatabaseDriver: abstraksi driver dicommonMain. Lima method:buatTabel,masukkanTugas,ambilSemuaTugas,tandaiSelesai,hapusTugas. PadaSQLDelightnyata, method ini dihasilkan dari file.sq.class RepositoriTugas: kode bisnis dicommonMain. MemakaiDatabaseDriveruntuk operasi database. Memuat aturan validasirequire(judul.isNotBlank())yang berjalan di semua platform.InMemoryDriver,AndroidSqliteDriverSimulasi,NativeSqliteDriverSimulasi: tiga implementasi driver. Pada proyek nyata,AndroidSqliteDrivermemakaiandroid.database.sqlite,NativeSqliteDrivermemakai SQLite C library via interop.- Output tiga platform identik: kode
RepositoriTugasyang sama menghasilkan struktur data identik di tiga platform. Hanya driver yang berbeda—kode bisnis tidak peduli. - Verifikasi SQL di akhir output: menampilkan SQL yang dijalankan driver nyata—
CREATE TABLE,INSERT,SELECT,UPDATE. Parameter binding (?) menjaga dari SQL injection.
Schema dan Migration #
Skema database didefinisikan sebagai objek Schema di commonMain. Saat aplikasi diperbarui dan struktur tabel berubah, kita menambahkan Migration yang menjalankan ALTER TABLE atau skrip perubahan. Migration juga bersifat lintas platform—kode migrasi yang sama dijalankan di setiap target.
object TugasDatabaseSchema : SqlSchema<SqlDriver> {
override val version: Long = 2
override fun create(driver: SqlDriver) {
driver.execute(null, """
CREATE TABLE tugas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
judul TEXT NOT NULL,
selesai INTEGER NOT NULL DEFAULT 0,
dibuat_pada TEXT NOT NULL
)
""".trimIndent(), 0)
}
override fun migrate(driver: SqlDriver, oldVersion: Long, newVersion: Long) {
if (oldVersion < 2) {
driver.execute(null, "ALTER TABLE tugas ADD COLUMN prioritas INTEGER DEFAULT 0", 0)
}
}
}Referensi Lanjutan #
- SQLDelight — halaman GitHub SQLDelight sebagai pustaka persistence multiplatform berbasis SQL
Penutup #
SQLDelight dan Room KMP mengikuti pola KMP yang sama dengan ktor-client—skema SQL bersama di commonMain, driver spesifik per platform. Kode bisnis RepositoriTugas tidak peduli platform; driver yang berubah. Chapter berikutnya akan masuk ke topik UI: pilihan toolkit UI untuk KMP, dari native (SwiftUI + Compose) hingga Compose Multiplatform yang berbagi UI lintas platform.