Setelah skema dibuat via migration, kita sering perlu mengisi data awal — peran default, akun admin awal, kode pos, atau kategori produk. Proses ini disebut seeding. Berbeda dengan migration yang mengubah skema, seeding mengisi data tanpa memengaruhi struktur tabel. Tantangannya adalah idempotency: seeding harus bisa dijalankan berulang tanpa menimbulkan duplikasi. Pada chapter ini kita akan membahas pola seeding idempoten dan kapan memakainya.
Kapan Seeding Dipakai #
Seeding cocok untuk beberapa kategori data:
- Data referensial: daftar peran (
admin,editor,viewer), kategori produk, kode pos. Data ini jarang berubah dan dibutuhkan agar aplikasi berfungsi. - Akun admin awal: user pertama yang dipakai untuk konfigurasi sistem. Password sebaiknya dari env var.
- Data demo: contoh data untuk lingkungan development atau staging agar developer bisa mencoba tanpa setup manual.
- Konfigurasi default: pengaturan aplikasi yang disimpan di tabel.
Seeding tidak cocok untuk data pengguna nyata, transaksi, atau data yang dihasilkan oleh aplikasi saat runtime.
Idempotency: Bisa Dijalankan Berulang #
Seeding yang baik bersifat idempoten: dijalankan satu kali atau sepuluh kali memberi hasil yang sama. Tanpa idempotency, seeding kedua kali akan menyisipkan duplikat atau melempar constraint violation.
Beberapa strategi idempotency:
| Strategi | SQL | Catatan |
|---|---|---|
INSERT OR IGNORE | SQLite: abaikan jika konflik PK/unique | SQLite spesifik |
ON CONFLICT DO NOTHING | PostgreSQL / SQLite modern | Standar SQL modern |
ON CONFLICT DO UPDATE | Upsert: insert atau update | Cocok untuk referensial |
MERGE INTO | Oracle / SQL Server | Sintaks standar SQL:2003 |
SELECT WHERE NOT EXISTS | Cek dulu lalu insert | Lambat bila banyak baris |
Pola Seeding di Spring Boot #
Spring Boot menyediakan beberapa cara untuk seeding. Yang paling umum adalah CommandLineRunner yang dijalankan saat startup:
@Component
class DataSeeder(val repo: PeranRepository) : CommandLineRunner {
override fun run(vararg args: String) {
val peranDefault = listOf(
Peran("admin", "Administrator"),
Peran("editor", "Editor"),
Peran("viewer", "Viewer")
)
for (p in peranDefault) {
if (!repo.existsByKode(p.kode)) {
repo.save(p)
}
}
}
}Alternatif: gunakan Flyway migration V2__seed_peran.sql yang langsung berisi INSERT OR IGNORE. Keuntungannya: migration dan seeding dilacak bersama di schema_version.
Kode Lengkap: Seeding Idempoten #
Kode berikut mendemonstrasikan seeding peran dan pengguna dengan tiga strategi idempotency yang berbeda: INSERT OR IGNORE, upsert dengan ON CONFLICT DO UPDATE, dan kontras dengan seeded yang tidak idempoten (akan menyisipkan duplikat).
// database_seeding.kt
// Mendemonstrasikan database seeding: mengisi data awal yang idempoten
// (bisa dijalankan berulang tanpa duplikasi). Menggunakan pola INSERT OR
// IGNORE (SQLite) dan INSERT ... ON CONFLICT DO NOTHING (PostgreSQL) untuk
// idempotency. Memakai SQLite via JDBC.
import java.sql.Connection
import java.sql.DriverManager
data class Peran(val kode: String, val nama: String)
data class Pengguna(val id: Int, val email: String, val nama: String, val peranKode: String)
class Seeder(private val conn: Connection) {
fun createSchema() {
conn.createStatement().use { it.execute(
"""CREATE TABLE peran (
kode TEXT PRIMARY KEY,
nama TEXT NOT NULL
)"""
) }
conn.createStatement().use { it.execute(
"""CREATE TABLE pengguna (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
nama TEXT NOT NULL,
peran_kode TEXT NOT NULL,
FOREIGN KEY (peran_kode) REFERENCES peran(kode)
)"""
) }
}
// Idempotent seed: insert jika belum ada, abaikan jika sudah ada.
// SQLite: INSERT OR IGNORE; PostgreSQL: INSERT ... ON CONFLICT DO NOTHING.
fun seedPeranIdempotent(roles: List<Peran>): Int {
var inserted = 0
conn.prepareStatement("INSERT OR IGNORE INTO peran(kode, nama) VALUES(?, ?)").use { ps ->
for (r in roles) {
ps.setString(1, r.kode)
ps.setString(2, r.nama)
inserted += ps.executeUpdate()
}
}
return inserted
}
// Idempotent seed untuk pengguna: pakai unique constraint email.
fun seedPenggunaIdempotent(users: List<Pengguna>): Int {
var inserted = 0
conn.prepareStatement(
"INSERT OR IGNORE INTO pengguna(email, nama, peran_kode) VALUES(?, ?, ?)"
).use { ps ->
for (u in users) {
ps.setString(1, u.email)
ps.setString(2, u.nama)
ps.setString(3, u.peranKode)
inserted += ps.executeUpdate()
}
}
return inserted
}
// Upsert (insert atau update jika sudah ada) — idempoten dengan update nilai.
fun upsertPeran(roles: List<Peran>): Int {
var affected = 0
// SQLite: ON CONFLICT(kode) DO UPDATE SET nama = excluded.nama
conn.prepareStatement(
"""INSERT INTO peran(kode, nama) VALUES(?, ?)
ON CONFLICT(kode) DO UPDATE SET nama = excluded.nama"""
).use { ps ->
for (r in roles) {
ps.setString(1, r.kode)
ps.setString(2, r.nama)
affected += ps.executeUpdate()
}
}
return affected
}
fun countPeran(): Int {
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT COUNT(*) FROM peran").use { rs ->
if (rs.next()) return rs.getInt(1)
}
}
return 0
}
fun countPengguna(): Int {
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT COUNT(*) FROM pengguna").use { rs ->
if (rs.next()) return rs.getInt(1)
}
}
return 0
}
fun listPeran(): List<Peran> {
val result = mutableListOf<Peran>()
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT kode, nama FROM peran ORDER BY kode").use { rs ->
while (rs.next()) result.add(Peran(rs.getString("kode"), rs.getString("nama")))
}
}
return result
}
fun listPengguna(): List<Pair<String, String>> {
val result = mutableListOf<Pair<String, String>>()
conn.createStatement().use { stmt ->
stmt.executeQuery("SELECT email, nama FROM pengguna ORDER BY email").use { rs ->
while (rs.next()) result.add(rs.getString("email") to rs.getString("nama"))
}
}
return result
}
}
fun main() {
Class.forName("org.sqlite.JDBC")
val url = "jdbc:sqlite:file::memory:?cache=shared"
val keeper: Connection = DriverManager.getConnection(url)
DriverManager.getConnection(url).use { conn ->
val seeder = Seeder(conn)
seeder.createSchema()
val peranAwal = listOf(
Peran("admin", "Administrator"),
Peran("editor", "Editor"),
Peran("viewer", "Viewer")
)
val penggunaAwal = listOf(
Pengguna(0, "[email protected]", "Budi Santoso", "admin"),
Pengguna(0, "[email protected]", "Siti Aminah", "editor"),
Pengguna(0, "[email protected]", "Andi Wijaya", "viewer")
)
println("=== Seed pertama (insert baru) ===")
val r1 = seeder.seedPeranIdempotent(peranAwal)
val u1 = seeder.seedPenggunaIdempotent(penggunaAwal)
println("Peran baru : $r1 dari ${peranAwal.size}")
println("Pengguna baru : $u1 dari ${penggunaAwal.size}")
println("Total peran : ${seeder.countPeran()}")
println("Total pengguna: ${seeder.countPengguna()}")
println("\n=== Seed kedua (idempotent, semua dilewati) ===")
val r2 = seeder.seedPeranIdempotent(peranAwal)
val u2 = seeder.seedPenggunaIdempotent(penggunaAwal)
println("Peran baru : $r2 (harusnya 0)")
println("Pengguna baru : $u2 (harusnya 0)")
println("Total peran : ${seeder.countPeran()}")
println("Total pengguna: ${seeder.countPengguna()}")
println("\n=== Upsert: ubah nama peran yang sudah ada ===")
val peranUpdated = listOf(
Peran("admin", "System Administrator"),
Peran("editor", "Content Editor"),
Peran("viewer", "Read-only Viewer")
)
val affected = seeder.upsertPeran(peranUpdated)
println("Baris terdampai: $affected (insert atau update)")
println("Peran setelah upsert:")
seeder.listPeran().forEach { println(" $it") }
println("\n=== Hasil akhir pengguna ===")
seeder.listPengguna().forEach { (email, nama) ->
println(" $email -> $nama")
}
println("\n=== Strategi idempotency ===")
println("| Strategi | SQL |")
println("|-------------------------|----------------------------------------------|")
println("| INSERT OR IGNORE | SQLite: abaikan jika konflik PK/unique |")
println("| ON CONFLICT DO NOTHING | PostgreSQL / SQLite modern |")
println("| ON CONFLICT DO UPDATE | Upsert: insert atau update |")
println("| MERGE INTO | Oracle/SQL Server |")
println("| Cek exists sebelum | SELECT WHERE NOT EXISTS lalu INSERT (lambat) |")
println("\n=== Kapan seeding dipakai ===")
println("- Data referensial: daftar peran, kode pos, kategori produk")
println("- Akun admin awal: user pertama dengan password terenkripsi")
println("- Data demo untuk lingkungan development/staging")
}
keeper.close()
}Output yang Dihasilkan:
=== Seed pertama (insert baru) ===
Peran baru : 3 dari 3
Pengguna baru : 3 dari 3
Total peran : 3
Total pengguna: 3
=== Seed kedua (idempotent, semua dilewati) ===
Peran baru : 0 (harusnya 0)
Pengguna baru : 0 (harusnya 0)
Total peran : 3
Total pengguna: 3
=== Upsert: ubah nama peran yang sudah ada ===
Baris terdampai: 3 (insert atau update)
Peran setelah upsert:
Peran(kode=admin, nama=System Administrator)
Peran(kode=editor, nama=Content Editor)
Peran(kode=viewer, nama=Read-only Viewer)
=== Hasil akhir pengguna ===
[email protected] -> Andi Wijaya
[email protected] -> Budi Santoso
[email protected] -> Siti Aminah
=== Strategi idempotency ===
| Strategi | SQL |
|-------------------------|----------------------------------------------|
| INSERT OR IGNORE | SQLite: abaikan jika konflik PK/unique |
| ON CONFLICT DO NOTHING | PostgreSQL / SQLite modern |
| ON CONFLICT DO UPDATE | Upsert: insert atau update |
| MERGE INTO | Oracle/SQL Server |
| Cek exists sebelum | SELECT WHERE NOT EXISTS lalu INSERT (lambat) |
=== Kapan seeding dipakai ===
- Data referensial: daftar peran, kode pos, kategori produk
- Akun admin awal: user pertama dengan password terenkripsi
- Data demo untuk lingkungan development/stagingSeeding vs Migration #
Pembedaan penting: migration mengubah skema, seeding mengisi data. Banyak tim mencampur keduanya dengan menambah INSERT ke migration V2 atau V3. Pendekatan ini sah bila data tersebut bagian dari definisi aplikasi (misalnya kode peran yang dipakai di kode aplikasi). Untuk data yang bisa berubah, simpan di tabel terpisah dan kelola via admin UI.
INSERT INTO peran(kode, nama) VALUES ('admin', 'Administrator') ON CONFLICT DO NOTHING;
INSERT INTO peran(kode, nama) VALUES ('editor', 'Editor') ON CONFLICT DO NOTHING;
INSERT INTO peran(kode, nama) VALUES ('viewer', 'Viewer') ON CONFLICT DO NOTHING;Aturan praktis: data yang mereferensi konstanta di kode aplikasi (seperti if (peran.kode == "admin")) sebaiknya disisipkan via migration agar selalu sinkron dengan kode. Data yang lebih fleksibel — daftar kategori produk, daftar promo — sebaiknya dikelola via admin UI atau skrip terpisah agar bisa diubah tanpa migrasi.
Referensi Lanjutan #
- Testcontainers — dokumentasi Testcontainers untuk seeding dan testing database ephemeral
Selanjutnya #
Database dengan skema dan data awal perlu diuji. Chapter Database Testing akan memperkenalkan Testcontainers, H2 in-memory, dan pola fixture per test untuk pengujian yang terisolasi.