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
maximumPoolSizesesuai dengan jumlah CPU core dan karakteristik query.- PreparedStatement wajib — tidak ada alasan menggunakan
Statement.execute(sqlString)dengan input dari pengguna.PreparedStatementmencegah 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 TRUEdan tandaiaktif = FALSEalih-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 massal —
addBatch()danexecuteBatch()jauh lebih efisien dari loop insert satu per satu. Untuk ribuan row, perbedaan performanya sangat signifikan.