Menulis SQL mentah di setiap operasi database cepat menjadi repetitif: INSERT, SELECT, UPDATE dengan struktur mirip berulang, sementara kita perlu memetakan kolom ke properti class secara manual. Object-Relational Mapping (ORM) adalah lapisan abstraksi yang memetakan class ke tabel dan otomatis menghasilkan SQL dari operasi objek. Pada chapter ini kita akan memahami konsep ORM, kelebihan dan kekurangannya, serta membangun mini-ORM sebagai demonstrasi cara kerjanya di balik layar.
Apa itu ORM #
ORM adalah teknik yang menjembatani object-oriented programming dengan relational database. Sebuah class Kotlin (sering disebut entity) dipetakan ke sebuah tabel SQL. Properti class dipetakan ke kolom. Instance class menjadi baris tabel. Operasi entity.save() diterjemahkan ORM menjadi INSERT, entity.delete() menjadi DELETE, dan findById(id) menjadi SELECT.
data class Pengguna(val id: Int, val nama: String, val email: String)
// Insert manual:
conn.prepareStatement("INSERT INTO pengguna(nama, email) VALUES(?, ?)").use { ps ->
ps.setString(1, p.nama); ps.setString(2, p.email); ps.executeUpdate()
}
// Read manual:
conn.prepareStatement("SELECT id, nama, email FROM pengguna WHERE id = ?").use { ps ->
ps.setInt(1, id)
ps.executeQuery().use { rs ->
if (rs.next()) Pengguna(rs.getInt("id"), rs.getString("nama"), rs.getString("email"))
}
}Dengan ORM, kode di atas menjadi:
@Entity(tableName = "pengguna")
data class Pengguna(
@Id(generated = true) val id: Long = 0,
@Column val nama: String,
@Column val email: String
)
em.persist(Pengguna(nama = "Budi", email = "[email protected]"))
val budi = em.find(Pengguna::class.java, 1)ORM membaca anotasi @Entity, @Id, @Column saat startup, membangun metadata, lalu menghasilkan SQL yang sesuai saat runtime.
Keuntungan dan Kerugian #
Keuntungan:
- Produktivitas tinggi: tidak perlu menulis SQL repetitif.
- Type-safe: kolom dan tipe divalidasi saat compile time (di ORM modern seperti Exposed).
- Abstraksi database: lebih mudah berpindah antara PostgreSQL, MySQL, atau SQLite.
- Otomatisasi relationship via
@OneToMany,@ManyToOne. - Migrasi skema otomatis (di Hibernate).
Kerugian:
- N+1 query: ambil daftar entity lalu akses relasi tiap entity menghasilkan 1 + N query.
- Kontrol SQL berkurang: sulit mengoptimalkan query kompleks.
- Startup overhead: scan anotasi dan bangun metadata saat aplikasi start.
- Abstraction leak: kasus lanjutan (CTE, window function, hint index) tetap perlu SQL mentah.
Kapan Memakai ORM #
ORM cocok untuk aplikasi dengan operasi CRUD dominan dan skema relatif standar. Untuk reporting berat dengan banyak JOIN dan agregasi, query SQL mentah atau query builder biasanya lebih efisien. Banyak aplikasi nyata memakai kombinasi: ORM untuk tulis (insert/update/delete entity) dan SQL mentah atau DTO untuk baca berat.
Pertimbangan lain adalah kompleksitas skema dan kebutuhan akan fitur spesifik database. Bila aplikasi memakai fitur PostgreSQL seperti JSONB, full-text search, atau window function, ORM generik sering tidak mendukung semua fitur tersebut dengan baik. Di sisi lain, untuk aplikasi dengan skema stabil dan query sederhana, ORM memberikan produktivitas tinggi tanpa overhead berarti. Tim yang baru memulai proyek sering memakai ORM untuk semua, lalu mengganti ke SQL mentah untuk bagian yang performance-critical.
Mini-ORM untuk Memahami Konsep #
Kode berikut mengimplementasikan mini-ORM dengan metadata eksplisit (di produksi: ORM memakai reflection untuk scan anotasi). Mini-ORM ini menghasilkan DDL, INSERT, SELECT, dan UPDATE dari metadata yang didaftarkan.
// orm_fundamentals.kt
// Mengenal Object-Relational Mapping (ORM): memetakan class Kotlin ke tabel
// SQL dan otomatis melakukan INSERT/SELECT/UPDATE. Pada kode produksi,
// Hibernate/Exposed/JPA memproses anotasi. Di sini kita memodelkannya
// sebagai mini-ORM dengan registrasi metadata eksplisit (tanpa reflection).
// Mock anotasi ORM (di produksi: javax.persistence.*)
@Target(AnnotationTarget.CLASS) annotation class Entity(val tableName: String = "")
@Target(AnnotationTarget.PROPERTY) annotation class Id(val generated: Boolean = false)
@Target(AnnotationTarget.PROPERTY) annotation class Column(val name: String = "")
@Target(AnnotationTarget.PROPERTY) annotation class Transient
// Metadata kolom: hasil scan manual atau deklarasi eksplisit
data class ColumnMeta(
val propertyName: String,
val columnName: String,
val isId: Boolean = false,
val isGenerated: Boolean = false,
val isTransient: Boolean = false,
val sqlType: String = "TEXT"
)
data class EntityMeta(
val klassName: String,
val tableName: String,
val columns: List<ColumnMeta>
) {
fun idColumn(): ColumnMeta = columns.first { it.isId }
fun nonTransientColumns(): List<ColumnMeta> = columns.filterNot { it.isTransient }
}
// Entity manager sederhana: cache metadata, generate SQL
class MiniOrm {
private val metas = mutableMapOf<String, EntityMeta>()
fun register(meta: EntityMeta) { metas[meta.klassName] = meta }
fun meta(klassName: String): EntityMeta? = metas[klassName]
// Generate CREATE TABLE
fun ddl(klassName: String): String {
val meta = metas[klassName] ?: error("Entity $klassName belum diregistrasi")
val cols = meta.nonTransientColumns().joinToString(",\n ") { c ->
val parts = mutableListOf(c.columnName, c.sqlType)
if (c.isId) {
parts.add("PRIMARY KEY")
if (c.isGenerated) parts.add("AUTOINCREMENT")
}
parts.joinToString(" ")
}
return "CREATE TABLE ${meta.tableName} (\n $cols\n);"
}
// Generate INSERT
fun insertSql(klassName: String, values: Map<String, Any?>): String {
val meta = metas[klassName] ?: error("Entity $klassName belum diregistrasi")
val cols = meta.nonTransientColumns().filter { !(it.isId && it.isGenerated) }
val colNames = cols.joinToString(", ") { it.columnName }
val colVals = cols.joinToString(", ") { c ->
val v = values[c.propertyName]
if (v is String) "'$v'" else v?.toString() ?: "NULL"
}
return "INSERT INTO ${meta.tableName} ($colNames) VALUES ($colVals);"
}
// Generate SELECT
fun selectSql(klassName: String, where: String? = null): String {
val meta = metas[klassName] ?: error("Entity $klassName belum diregistrasi")
val cols = meta.nonTransientColumns().joinToString(", ") { it.columnName }
val w = if (where != null) " WHERE $where" else ""
return "SELECT $cols FROM ${meta.tableName}$w;"
}
// Generate UPDATE
fun updateSql(klassName: String, sets: Map<String, Any?>, idValue: Any): String {
val meta = metas[klassName] ?: error("Entity $klassName belum diregistrasi")
val setClause = sets.entries.joinToString(", ") { (prop, v) ->
val colName = meta.columns.firstOrNull { it.propertyName == prop }?.columnName ?: prop
val valStr = if (v is String) "'$v'" else v?.toString() ?: "NULL"
"$colName = $valStr"
}
val idCol = meta.idColumn().columnName
val idValStr = if (idValue is String) "'$idValue'" else idValue.toString()
return "UPDATE ${meta.tableName} SET $setClause WHERE $idCol = $idValStr;"
}
}
// Demo entity dengan anotasi (di produksi Hibernate yang memprosesnya)
@Entity(tableName = "produk")
data class Produk(
@Id(generated = true) val id: Long = 0,
@Column(name = "nama") val nama: String,
@Column(name = "harga") val harga: Int,
@Transient val cacheHash: Int = 0
)
// Registrasi metadata manual (di produksi: ORM scan anotasi via reflection)
fun registerProduk(orm: MiniOrm) {
orm.register(EntityMeta(
klassName = "Produk",
tableName = "produk",
columns = listOf(
ColumnMeta("id", "id", isId = true, isGenerated = true, sqlType = "INTEGER"),
ColumnMeta("nama", "nama", sqlType = "TEXT"),
ColumnMeta("harga", "harga", sqlType = "INTEGER"),
ColumnMeta("cacheHash", "cacheHash", isTransient = true)
)
))
}
fun main() {
val orm = MiniOrm()
registerProduk(orm)
println("=== Scan metadata entity ===")
val meta = orm.meta("Produk")!!
println("Class: ${meta.klassName}")
println("Table: ${meta.tableName}")
meta.columns.forEach { c ->
val flags = buildList {
if (c.isId) add("ID")
if (c.isGenerated) add("AUTO")
if (c.isTransient) add("TRANSIENT")
}
println(" ${c.propertyName} -> ${c.columnName} ${c.sqlType} ${flags.joinToString(",")}")
}
println("\n=== Generate DDL ===")
println(orm.ddl("Produk"))
println("\n=== Generate INSERT ===")
println(orm.insertSql("Produk", mapOf("id" to 0L, "nama" to "Kopi Susu", "harga" to 18000)))
println("\n=== Generate SELECT ===")
println(orm.selectSql("Produk"))
println(orm.selectSql("Produk", where = "harga > 10000"))
println("\n=== Generate UPDATE ===")
println(orm.updateSql("Produk", mapOf("nama" to "Kopi Susu Premium", "harga" to 22000), idValue = 1))
println("\n=== Konsep ORM ===")
println("| Konsep | Manual JDBC | ORM |")
println("|-------------------|------------------------------|------------------------------|")
println("| Map class<->table | Tidak ada, manual mapping | Otomatis via @Entity/@Column |")
println("| INSERT | Tulis SQL manual | Di-generate dari objek |")
println("| SELECT | getString/getInt per kolom | Otomatis jadi objek Kotlin |")
println("| Relationship | Manual JOIN | @OneToMany/@ManyToOne |")
println("| Migration | Manual ALTER TABLE | DDL auto-generate (Hibernate)|")
println("\n=== Tradeoff ORM ===")
println("Keuntungan: produktivitas tinggi, type-safe, otomatis mapping")
println("Kerugian: N+1 query, kontrol SQL berkurang, overhead startup")
println("Solusi: gunakan DTO untuk read berat, entity untuk write, dan monitoring query.")
}Output yang Dihasilkan:
=== Scan metadata entity ===
Class: Produk
Table: produk
id -> id INTEGER ID,AUTO
nama -> nama TEXT
harga -> harga INTEGER
cacheHash -> cacheHash TEXT TRANSIENT
=== Generate DDL ===
CREATE TABLE produk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT,
harga INTEGER
);
=== Generate INSERT ===
INSERT INTO produk (nama, harga) VALUES ('Kopi Susu', 18000);
=== Generate SELECT ===
SELECT id, nama, harga FROM produk;
SELECT id, nama, harga FROM produk WHERE harga > 10000;
=== Generate UPDATE ===
UPDATE produk SET nama = 'Kopi Susu Premium', harga = 22000 WHERE id = 1;
=== Konsep ORM ===
| Konsep | Manual JDBC | ORM |
|-------------------|------------------------------|------------------------------|
| Map class<->table | Tidak ada, manual mapping | Otomatis via @Entity/@Column |
| INSERT | Tulis SQL manual | Di-generate dari objek |
| SELECT | getString/getInt per kolom | Otomatis jadi objek Kotlin |
| Relationship | Manual JOIN | @OneToMany/@ManyToOne |
| Migration | Manual ALTER TABLE | DDL auto-generate (Hibernate)|
=== Tradeoff ORM ===
Keuntungan: produktivitas tinggi, type-safe, otomatis mapping
Kerugian: N+1 query, kontrol SQL berkurang, overhead startup
Solusi: gunakan DTO untuk read berat, entity untuk write, dan monitoring query.Jebakan N+1 Query #
Salah satu jebakan paling umum ORM adalah N+1 query: ambil daftar N entity, lalu akses relasi tiap entity menghasilkan 1 + N query. Contoh klasik:
val penulis = em.findAll(Penulis::class.java) // query 1
for (p in penulis) {
val buku = p.buku // query 2..N+1 (lazy fetch per penulis)
println("${p.nama}: ${buku.size} buku")
}Solusi: gunakan JOIN FETCH di JPQL (SELECT p FROM Penulis p LEFT JOIN FETCH p.buku) atau @EntityGraph untuk menentukan relasi yang ikut dimuat. Pemantauan query database di lingkungan development penting untuk mendeteksi N+1 sejak dini.
Referensi Lanjutan #
- JetBrains/Exposed — repository resmi Exposed, ORM idiomatik Kotlin dari JetBrains
- Hibernate ORM — dokumentasi resmi Hibernate sebagai pembanding ORM berbasis JPA
Selanjutnya #
Setelah memahami konsep ORM, kita akan melihat implementasi konkret dengan Exposed — ORM/ query builder resmi dari JetBrains. Chapter Exposed Framework akan memperkenalkan DSL type-safe Exposed untuk mendefinisikan tabel dan menulis query.