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.

AspekSQLDelightRoom KMP
Sumber queryFile .sq dengan SQL mentahAnotasi @Query, @Insert
Bahasa schemaSQLKotlin class dengan anotasi
StabilitasStable, banyak dipakaiStabil sejak Room 2.7
DriverSqlDriver interfaceRoomDatabase.Builder
MigrationMigration dengan SQLMigration 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.

kotlin build.gradle.kts: SQLDelight dependencies
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")
        }
    }
}
kotlin androidMain: inisialisasi driver
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")
    }
}
kotlin iosMain: inisialisasi driver
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.

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

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

  1. data class Tugas: model data di commonMain. Field-nya hanya tipe primitif (Long, String, Boolean) sehingga dapat dipetakan ke kolom SQLite.
  2. interface DatabaseDriver: abstraksi driver di commonMain. Lima method: buatTabel, masukkanTugas, ambilSemuaTugas, tandaiSelesai, hapusTugas. Pada SQLDelight nyata, method ini dihasilkan dari file .sq.
  3. class RepositoriTugas: kode bisnis di commonMain. Memakai DatabaseDriver untuk operasi database. Memuat aturan validasi require(judul.isNotBlank()) yang berjalan di semua platform.
  4. InMemoryDriver, AndroidSqliteDriverSimulasi, NativeSqliteDriverSimulasi: tiga implementasi driver. Pada proyek nyata, AndroidSqliteDriver memakai android.database.sqlite, NativeSqliteDriver memakai SQLite C library via interop.
  5. Output tiga platform identik: kode RepositoriTugas yang sama menghasilkan struktur data identik di tiga platform. Hanya driver yang berbeda—kode bisnis tidak peduli.
  6. 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.

kotlin commonMain: schema dan migration
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.