MySQL

MySQL #

MySQL adalah sistem manajemen basis data relasional yang paling banyak digunakan di dunia — mendukung jutaan aplikasi web, dari blog sederhana hingga platform e-commerce skala besar. Di Kotlin, ada beberapa cara berinteraksi dengan MySQL: JDBC (Java Database Connectivity, API tingkat rendah yang memberi kontrol penuh), Exposed (library ORM dari JetBrains yang Kotlin-native), dan framework ORM lain seperti Hibernate atau Spring Data JPA. Artikel ini membahas JDBC untuk memahami dasar-dasarnya, lalu Exposed sebagai pilihan idiomatis untuk proyek Kotlin, lengkap dengan connection pooling, transaction, dan pola repository.

Menyiapkan Koneksi MySQL #

Dependensi #

// build.gradle.kts
dependencies {
    // MySQL JDBC Driver
    implementation("com.mysql:mysql-connector-j:8.3.0")

    // HikariCP — connection pool terbaik untuk JVM
    implementation("com.zaxxer:HikariCP:5.1.0")

    // Exposed ORM (opsional, alternatif dari JDBC langsung)
    implementation("org.jetbrains.exposed:exposed-core:0.49.0")
    implementation("org.jetbrains.exposed:exposed-dao:0.49.0")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.49.0")
    implementation("org.jetbrains.exposed:exposed-java-time:0.49.0")

    // Flyway untuk database migration
    implementation("org.flywaydb:flyway-core:10.10.0")
    implementation("org.flywaydb:flyway-mysql:10.10.0")
}

Koneksi JDBC Dasar #

import java.sql.DriverManager

fun main() {
    val url = "jdbc:mysql://localhost:3306/myapp?useSSL=false&serverTimezone=Asia/Jakarta&characterEncoding=UTF-8"
    val pengguna = "root"
    val sandi = "password"

    // Koneksi sederhana — buka dan tutup manual
    DriverManager.getConnection(url, pengguna, sandi).use { koneksi ->
        println("Terhubung ke MySQL: ${koneksi.metaData.databaseProductVersion}")

        // Eksekusi query sederhana
        koneksi.createStatement().use { stmt ->
            val hasil = stmt.executeQuery("SELECT VERSION()")
            if (hasil.next()) {
                println("Versi MySQL: ${hasil.getString(1)}")
            }
        }
    }
    // use{} memastikan koneksi selalu ditutup
}

HikariCP — Connection Pool #

Membuat koneksi database baru setiap kali request datang sangat mahal — butuh handshake TCP, autentikasi, dan alokasi resource di sisi server. Connection pool mengelola sekumpulan koneksi yang sudah terbuka dan siap pakai, sangat meningkatkan throughput aplikasi.

import com.zaxxer.hikari.HikariConfig
import com.zaxxer.hikari.HikariDataSource

object DatabasePool {
    private val dataSource: HikariDataSource by lazy {
        val config = HikariConfig().apply {
            jdbcUrl = "jdbc:mysql://localhost:3306/myapp?useSSL=false&serverTimezone=Asia/Jakarta&characterEncoding=UTF-8"
            username = System.getenv("DB_USER") ?: "root"
            password = System.getenv("DB_PASSWORD") ?: "password"
            driverClassName = "com.mysql.cj.jdbc.Driver"

            // Pool configuration
            minimumIdle = 2          // minimum koneksi idle
            maximumPoolSize = 10     // maksimum koneksi dalam pool
            idleTimeout = 300_000    // 5 menit — tutup koneksi idle terlalu lama
            connectionTimeout = 20_000 // 20 detik — timeout saat ambil koneksi dari pool
            maxLifetime = 1_800_000  // 30 menit — paksa ganti koneksi lama
            validationTimeout = 5_000

            // Nama pool untuk monitoring
            poolName = "MyApp-MySQL-Pool"

            // Query untuk validasi koneksi masih hidup
            connectionTestQuery = "SELECT 1"
        }
        HikariDataSource(config)
    }

    fun <T> gunakan(blok: (java.sql.Connection) -> T): T {
        return dataSource.connection.use(blok)
    }

    fun tutup() {
        if (!dataSource.isClosed) dataSource.close()
    }
}

// Penggunaan
fun main() {
    DatabasePool.gunakan { koneksi ->
        println("Dapat koneksi dari pool: ${koneksi.metaData.databaseProductVersion}")
    }

    // Tambahkan shutdown hook
    Runtime.getRuntime().addShutdownHook(Thread {
        DatabasePool.tutup()
        println("Connection pool ditutup")
    })
}

CRUD dengan JDBC dan PreparedStatement #

PreparedStatement adalah cara yang benar untuk menjalankan query dengan parameter — ia mencegah SQL injection dan meningkatkan performa karena query di-precompile.

Schema Tabel #

CREATE TABLE IF NOT EXISTS produk (
    id         INT AUTO_INCREMENT PRIMARY KEY,
    nama       VARCHAR(255)   NOT NULL,
    deskripsi  TEXT,
    harga      DECIMAL(15,2)  NOT NULL,
    stok       INT            NOT NULL DEFAULT 0,
    kategori   VARCHAR(100),
    aktif      BOOLEAN        NOT NULL DEFAULT TRUE,
    dibuat_pada TIMESTAMP     DEFAULT CURRENT_TIMESTAMP,
    diperbarui_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

Model dan Repository #

import java.math.BigDecimal
import java.sql.Connection
import java.sql.ResultSet
import java.sql.Timestamp

data class Produk(
    val id: Int = 0,
    val nama: String,
    val deskripsi: String? = null,
    val harga: BigDecimal,
    val stok: Int = 0,
    val kategori: String? = null,
    val aktif: Boolean = true
)

class ProdukRepository {

    private fun ResultSet.toProduk() = Produk(
        id        = getInt("id"),
        nama      = getString("nama"),
        deskripsi = getString("deskripsi"),
        harga     = getBigDecimal("harga"),
        stok      = getInt("stok"),
        kategori  = getString("kategori"),
        aktif     = getBoolean("aktif")
    )

    // CREATE
    fun simpan(produk: Produk): Produk {
        val sql = """
            INSERT INTO produk (nama, deskripsi, harga, stok, kategori, aktif)
            VALUES (?, ?, ?, ?, ?, ?)
        """.trimIndent()

        return DatabasePool.gunakan { koneksi ->
            koneksi.prepareStatement(sql, java.sql.Statement.RETURN_GENERATED_KEYS).use { stmt ->
                stmt.setString(1, produk.nama)
                stmt.setString(2, produk.deskripsi)
                stmt.setBigDecimal(3, produk.harga)
                stmt.setInt(4, produk.stok)
                stmt.setString(5, produk.kategori)
                stmt.setBoolean(6, produk.aktif)
                stmt.executeUpdate()

                // Ambil ID yang di-generate
                stmt.generatedKeys.use { keys ->
                    if (keys.next()) {
                        produk.copy(id = keys.getInt(1))
                    } else {
                        throw RuntimeException("Gagal mendapatkan ID yang di-generate")
                    }
                }
            }
        }
    }

    // READ — cari satu
    fun cariById(id: Int): Produk? {
        val sql = "SELECT * FROM produk WHERE id = ? AND aktif = TRUE"

        return DatabasePool.gunakan { koneksi ->
            koneksi.prepareStatement(sql).use { stmt ->
                stmt.setInt(1, id)
                stmt.executeQuery().use { rs ->
                    if (rs.next()) rs.toProduk() else null
                }
            }
        }
    }

    // READ — cari semua dengan filter
    fun cariSemua(
        kategori: String? = null,
        hargaMaks: BigDecimal? = null,
        aktifSaja: Boolean = true,
        limit: Int = 20,
        offset: Int = 0
    ): List<Produk> {
        val kondisi = mutableListOf("1=1")
        val params = mutableListOf<Any?>()

        if (aktifSaja) { kondisi.add("aktif = TRUE") }
        if (kategori != null) {
            kondisi.add("kategori = ?")
            params.add(kategori)
        }
        if (hargaMaks != null) {
            kondisi.add("harga <= ?")
            params.add(hargaMaks)
        }

        val sql = """
            SELECT * FROM produk
            WHERE ${kondisi.joinToString(" AND ")}
            ORDER BY nama
            LIMIT ? OFFSET ?
        """.trimIndent()

        return DatabasePool.gunakan { koneksi ->
            koneksi.prepareStatement(sql).use { stmt ->
                var indeks = 1
                params.forEach { param ->
                    when (param) {
                        is String -> stmt.setString(indeks++, param)
                        is BigDecimal -> stmt.setBigDecimal(indeks++, param)
                        else -> stmt.setObject(indeks++, param)
                    }
                }
                stmt.setInt(indeks++, limit)
                stmt.setInt(indeks, offset)

                stmt.executeQuery().use { rs ->
                    buildList {
                        while (rs.next()) add(rs.toProduk())
                    }
                }
            }
        }
    }

    // UPDATE
    fun perbarui(produk: Produk): Boolean {
        val sql = """
            UPDATE produk
            SET nama = ?, deskripsi = ?, harga = ?, stok = ?, kategori = ?
            WHERE id = ?
        """.trimIndent()

        return DatabasePool.gunakan { koneksi ->
            koneksi.prepareStatement(sql).use { stmt ->
                stmt.setString(1, produk.nama)
                stmt.setString(2, produk.deskripsi)
                stmt.setBigDecimal(3, produk.harga)
                stmt.setInt(4, produk.stok)
                stmt.setString(5, produk.kategori)
                stmt.setInt(6, produk.id)
                stmt.executeUpdate() > 0
            }
        }
    }

    // DELETE — soft delete (tandai tidak aktif)
    fun hapus(id: Int): Boolean {
        val sql = "UPDATE produk SET aktif = FALSE WHERE id = ?"

        return DatabasePool.gunakan { koneksi ->
            koneksi.prepareStatement(sql).use { stmt ->
                stmt.setInt(1, id)
                stmt.executeUpdate() > 0
            }
        }
    }

    // Batch insert — efisien untuk banyak data sekaligus
    fun simpanBanyak(daftar: List<Produk>): Int {
        val sql = "INSERT INTO produk (nama, harga, stok, kategori) VALUES (?, ?, ?, ?)"

        return DatabasePool.gunakan { koneksi ->
            koneksi.prepareStatement(sql).use { stmt ->
                daftar.forEach { produk ->
                    stmt.setString(1, produk.nama)
                    stmt.setBigDecimal(2, produk.harga)
                    stmt.setInt(3, produk.stok)
                    stmt.setString(4, produk.kategori)
                    stmt.addBatch()
                }
                stmt.executeBatch().sum()
            }
        }
    }
}

Transaction #

Transaction memastikan serangkaian operasi database berhasil semua atau gagal semua — tidak ada kondisi setengah jadi:

fun transferStok(dariId: Int, keId: Int, jumlah: Int) {
    DatabasePool.gunakan { koneksi ->
        // Nonaktifkan auto-commit untuk mulai transaction
        koneksi.autoCommit = false

        try {
            // Kurangi stok dari produk sumber
            val sqlKurang = "UPDATE produk SET stok = stok - ? WHERE id = ? AND stok >= ?"
            koneksi.prepareStatement(sqlKurang).use { stmt ->
                stmt.setInt(1, jumlah)
                stmt.setInt(2, dariId)
                stmt.setInt(3, jumlah)
                val terkena = stmt.executeUpdate()
                if (terkena == 0) throw IllegalStateException("Stok tidak cukup atau produk tidak ditemukan")
            }

            // Tambah stok ke produk tujuan
            val sqlTambah = "UPDATE produk SET stok = stok + ? WHERE id = ?"
            koneksi.prepareStatement(sqlTambah).use { stmt ->
                stmt.setInt(1, jumlah)
                stmt.setInt(2, keId)
                val terkena = stmt.executeUpdate()
                if (terkena == 0) throw IllegalStateException("Produk tujuan tidak ditemukan")
            }

            // Commit jika semua berhasil
            koneksi.commit()
            println("Transfer $jumlah unit dari produk $dariId ke $keId berhasil")

        } catch (e: Exception) {
            // Rollback jika ada yang gagal
            koneksi.rollback()
            println("Transfer gagal, rollback: ${e.message}")
            throw e

        } finally {
            // Kembalikan ke auto-commit
            koneksi.autoCommit = true
        }
    }
}

Exposed ORM — Pendekatan Idiomatis Kotlin #

Exposed dari JetBrains adalah ORM Kotlin-native yang menyediakan dua API: DSL (query builder type-safe) dan DAO (active record pattern). Keduanya bisa digunakan bersamaan.

Definisi Tabel #

import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.javatime.*
import org.jetbrains.exposed.dao.*
import org.jetbrains.exposed.dao.id.*
import java.time.LocalDateTime

// DSL API: definisi tabel sebagai object
object TabelProduk : IntIdTable("produk") {
    val nama       = varchar("nama", 255)
    val deskripsi  = text("deskripsi").nullable()
    val harga      = decimal("harga", 15, 2)
    val stok       = integer("stok").default(0)
    val kategori   = varchar("kategori", 100).nullable()
    val aktif      = bool("aktif").default(true)
    val dibuatPada = datetime("dibuat_pada").defaultExpression(CurrentDateTime)
}

object TabelPengguna : IntIdTable("pengguna") {
    val nama       = varchar("nama", 255)
    val email      = varchar("email", 255).uniqueIndex()
    val passwordHash = varchar("password_hash", 255)
    val aktif      = bool("aktif").default(true)
}

// DAO API: entity class
class ProdukEntity(id: EntityID<Int>) : IntEntity(id) {
    companion object : IntEntityClass<ProdukEntity>(TabelProduk)

    var nama       by TabelProduk.nama
    var deskripsi  by TabelProduk.deskripsi
    var harga      by TabelProduk.harga
    var stok       by TabelProduk.stok
    var kategori   by TabelProduk.kategori
    var aktif      by TabelProduk.aktif
    var dibuatPada by TabelProduk.dibuatPada
}

Setup Database dan Transaction #

import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.SchemaUtils
import org.jetbrains.exposed.sql.transactions.transaction

fun inisialisasiDatabase() {
    Database.connect(
        url = "jdbc:mysql://localhost:3306/myapp?useSSL=false&serverTimezone=Asia/Jakarta",
        driver = "com.mysql.cj.jdbc.Driver",
        user = System.getenv("DB_USER") ?: "root",
        password = System.getenv("DB_PASSWORD") ?: "password"
    )

    // Atau dengan HikariDataSource yang sudah dibuat
    // Database.connect(DatabasePool.dataSource)

    // Buat tabel jika belum ada (untuk development)
    transaction {
        SchemaUtils.create(TabelProduk, TabelPengguna)
    }
}

CRUD dengan Exposed DSL #

import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import java.math.BigDecimal

// INSERT
fun tambahProdukDsl(nama: String, harga: BigDecimal, stok: Int): Int {
    return transaction {
        TabelProduk.insertAndGetId {
            it[TabelProduk.nama]     = nama
            it[TabelProduk.harga]    = harga
            it[TabelProduk.stok]     = stok
        }.value
    }
}

// SELECT
fun ambilSemuaProdukDsl(kategori: String? = null): List<ResultRow> {
    return transaction {
        TabelProduk
            .select { TabelProduk.aktif eq true }
            .apply { if (kategori != null) andWhere { TabelProduk.kategori eq kategori } }
            .orderBy(TabelProduk.nama)
            .toList()
    }
}

// SELECT dengan join
fun ambilProdukDenganPenjual() {
    transaction {
        (TabelProduk innerJoin TabelPengguna)
            .select { TabelProduk.aktif eq true }
            .forEach { baris ->
                println("${baris[TabelProduk.nama]}${baris[TabelPengguna.nama]}")
            }
    }
}

// UPDATE
fun perbaruiHarga(id: Int, hargaBaru: BigDecimal): Int {
    return transaction {
        TabelProduk.update({ TabelProduk.id eq id }) {
            it[harga] = hargaBaru
        }
    }
}

// DELETE
fun hapusProdukDsl(id: Int): Int {
    return transaction {
        TabelProduk.update({ TabelProduk.id eq id }) {
            it[aktif] = false  // soft delete
        }
    }
}

CRUD dengan Exposed DAO #

// INSERT
fun tambahProdukDao(nama: String, harga: BigDecimal, stok: Int): ProdukEntity {
    return transaction {
        ProdukEntity.new {
            this.nama  = nama
            this.harga = harga
            this.stok  = stok
            this.aktif = true
        }
    }
}

// SELECT
fun cariProdukDao(id: Int): ProdukEntity? {
    return transaction {
        ProdukEntity.findById(id)
    }
}

fun semuaProdukAktifDao(): List<ProdukEntity> {
    return transaction {
        ProdukEntity.find { TabelProduk.aktif eq true }
            .orderBy(TabelProduk.nama to SortOrder.ASC)
            .toList()
    }
}

// UPDATE
fun perbaruiProdukDao(id: Int, namaBaru: String, hargaBaru: BigDecimal): Boolean {
    return transaction {
        val produk = ProdukEntity.findById(id) ?: return@transaction false
        produk.nama  = namaBaru
        produk.harga = hargaBaru
        true
    }
}

// DELETE
fun hapusProdukDao(id: Int): Boolean {
    return transaction {
        val produk = ProdukEntity.findById(id) ?: return@transaction false
        produk.aktif = false
        true
    }
}

Migrasi Database dengan Flyway #

Flyway mengelola perubahan schema database secara terkontrol — setiap perubahan ditulis sebagai file SQL bernomor urut, dan Flyway melacak versi yang sudah dijalankan:

// build.gradle.kts — sudah ditambahkan di atas

// Struktur file migrasi:
// src/main/resources/db/migration/
//   ├── V1__create_pengguna.sql
//   ├── V2__create_produk.sql
//   ├── V3__add_kategori_index.sql
//   └── V4__add_pesanan.sql
-- V1__create_pengguna.sql
CREATE TABLE pengguna (
    id            INT AUTO_INCREMENT PRIMARY KEY,
    nama          VARCHAR(255) NOT NULL,
    email         VARCHAR(255) NOT NULL UNIQUE,
    password_hash VARCHAR(255) NOT NULL,
    aktif         BOOLEAN NOT NULL DEFAULT TRUE,
    dibuat_pada   TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- V2__create_produk.sql
CREATE TABLE produk (
    id              INT AUTO_INCREMENT PRIMARY KEY,
    nama            VARCHAR(255)  NOT NULL,
    deskripsi       TEXT,
    harga           DECIMAL(15,2) NOT NULL,
    stok            INT NOT NULL DEFAULT 0,
    kategori        VARCHAR(100),
    aktif           BOOLEAN NOT NULL DEFAULT TRUE,
    dibuat_pada     TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    diperbarui_pada TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

-- V3__add_kategori_index.sql
CREATE INDEX idx_produk_kategori ON produk(kategori);
CREATE INDEX idx_produk_aktif ON produk(aktif);
import org.flywaydb.core.Flyway

fun jalankanMigrasi() {
    val flyway = Flyway.configure()
        .dataSource(
            "jdbc:mysql://localhost:3306/myapp?useSSL=false",
            System.getenv("DB_USER") ?: "root",
            System.getenv("DB_PASSWORD") ?: "password"
        )
        .locations("classpath:db/migration")  // lokasi file SQL
        .baselineOnMigrate(true)              // untuk database yang sudah ada
        .validateOnMigrate(true)              // validasi checksum file
        .load()

    val hasil = flyway.migrate()
    println("Migrasi berhasil: ${hasil.migrationsExecuted} migrasi dijalankan")
}

fun main() {
    jalankanMigrasi()          // jalankan migrasi dulu
    inisialisasiDatabase()     // baru setup Exposed
    // ... jalankan aplikasi
}

Mencegah SQL Injection #

SQL injection adalah serangan paling umum di aplikasi database. Selalu gunakan PreparedStatement atau ORM, jangan pernah menggabungkan input pengguna ke string SQL:

// ANTI-PATTERN: SQL injection — JANGAN LAKUKAN INI!
fun cariProdukBuruk(nama: String): List<Produk> {
    DatabasePool.gunakan { koneksi ->
        // Jika nama = "' OR '1'='1" → query mengembalikan SEMUA data!
        // Jika nama = "'; DROP TABLE produk; --" → TABEL TERHAPUS!
        val sql = "SELECT * FROM produk WHERE nama = '$nama'"
        // ...
    }
    return emptyList()
}

// BENAR: PreparedStatement dengan parameter placeholder
fun cariProdukAman(nama: String): List<Produk> {
    return DatabasePool.gunakan { koneksi ->
        koneksi.prepareStatement("SELECT * FROM produk WHERE nama = ?").use { stmt ->
            stmt.setString(1, nama)  // MySQL menangani escaping otomatis
            stmt.executeQuery().use { rs ->
                buildList { while (rs.next()) add(rs.toProduk()) }
            }
        }
    }
}

// BENAR: Exposed DSL juga aman secara default
fun cariProdukExposed(nama: String) = transaction {
    TabelProduk.select { TabelProduk.nama eq nama }.toList()
    // Exposed menggunakan PreparedStatement di balik layar
}

Ringkasan #

  • Selalu gunakan connection pool — jangan buat koneksi baru per request. HikariCP adalah pilihan terbaik untuk JVM — konfigurasi maximumPoolSize sesuai dengan jumlah CPU core dan karakteristik query.
  • PreparedStatement wajib — tidak ada alasan menggunakan Statement.execute(sqlString) dengan input dari pengguna. PreparedStatement mencegah SQL injection dan lebih efisien karena query di-precompile.
  • Exposed untuk kode yang lebih bersih — DSL Exposed memberikan type-safety, auto-complete IDE, dan query yang lebih mudah dibaca. DAO API cocok untuk kode berorientasi objek.
  • Transaction untuk operasi yang berkaitan — setiap operasi yang melibatkan beberapa tabel atau beberapa langkah harus dijalankan dalam satu transaction. Jika ada yang gagal, semua di-rollback.
  • Soft delete daripada hard delete — tambahkan kolom aktif BOOLEAN DEFAULT TRUE dan tandai aktif = FALSE alih-alih menghapus row. Ini memudahkan recovery dan audit trail.
  • Flyway untuk migrasi schema — jangan modifikasi database production secara manual. Semua perubahan schema harus lewat file migrasi berurutan yang di-review dan di-version control.
  • Environment variable untuk kredensial — jangan hardcode URL, username, dan password database di kode atau file konfigurasi yang masuk git. Gunakan environment variable.
  • Batch insert untuk data massaladdBatch() dan executeBatch() jauh lebih efisien dari loop insert satu per satu. Untuk ribuan row, perbedaan performanya sangat signifikan.

← Sebelumnya: YAML   Berikutnya: MSSQL →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact