CSV (Comma-Separated Values) adalah format pertukaran data paling sederhana—setiap baris adalah record, setiap field dipisahkan koma. Meskipun sederhana, CSV punya aturan escaping yang sering diabaikan: field yang mengandung koma harus dibungkus tanda kutip; tanda kutip di dalam field harus di-escape dengan dua kutip. Parser naif yang hanya memakai split(",") akan rusak saat menemukan field seperti "Budi, S.T." atau "Citra \"Cici\" Wijaya". Pada chapter ini kita akan memahami standar RFC 4180, melihat parser dan writer yang compliant, serta streaming reader untuk file CSV berukuran besar yang tidak muat di memori.

Standar RFC 4180 #

RFC 4180 mendefinisikan format CSV standar. Aturan utama: setiap record dipisahkan CRLF (\r\n); field dipisahkan koma; field yang mengandung koma, tanda kutip, atau newline harus dibungkus tanda kutip ganda; tanda kutip di dalam field quoted di-escape dengan dua tanda kutip ("").

csv Contoh CSV dengan escaping
id,nama,departemen,gaji,aktif
1,Budi Santoso,Engineering,15000000,true
4,"Andi Pratama, S.T.",HR,10000000,false
5,"Citra ""Cici"" Wijaya",Finance,14000000,true

Baris ke-3 dan ke-4 menunjukkan escaping: "Andi Pratama, S.T." dibungkus kutip karena mengandung koma; "Citra ""Cici"" Wijaya" membungkus dan men-escape kutip dengan "".

Writer dan Reader Manual #

Demo berikut menunjukkan writer dan reader CSV yang compliant RFC 4180, type-safe mapping ke data class Pegawai, dan streaming reader untuk file besar.

kotlin
// csv_processing.kt
// CSV processing: parsing, type-safe mapping, streaming untuk file besar.
// Tanpa library eksternal — manual reader dengan RFC 4180 compliance.

import java.io.StringReader
import java.io.BufferedReader

// --- Model data ---
data class Pegawai(
    val id: Long,
    val nama: String,
    val departemen: String,
    val gaji: Long,
    val aktif: Boolean
)

// --- CSV Writer ---
object CsvWriter {
    fun write(row: List<String>): String {
        return row.joinToString(",") { escape(it) }
    }

    fun writeAll(rows: List<List<String>>, header: List<String>? = null): String {
        val sb = StringBuilder()
        if (header != null) sb.append(write(header)).append('\n')
        for (row in rows) {
            sb.append(write(row)).append('\n')
        }
        return sb.toString()
    }

    private fun escape(s: String): String {
        // RFC 4180: bila ada koma, kutip, atau newline, bungkus dengan " dan escape " dengan ""
        return if (',' in s || '"' in s || '\n' in s || '\r' in s) {
            "\"" + s.replace("\"", "\"\"") + "\""
        } else {
            s
        }
    }
}

// --- CSV Reader (RFC 4180) ---
class CsvReader(private val delimiter: Char = ',') {
    fun parse(text: String): List<List<String>> {
        val rows = mutableListOf<List<String>>()
        val reader = BufferedReader(StringReader(text))
        var line: String? = reader.readLine()
        val buffer = StringBuilder()
        var inQuotes = false
        var currentRow = mutableListOf<String>()
        var field = StringBuilder()

        // State machine untuk handle quoted field yang berisi newline
        for (line2 in text.split("\n")) {
            processLine(line2, inQuotes, field, currentRow, rows)
            // State machine kompleks - kita pakai pendekatan char-by-char
        }

        // Re-implement: char-by-char parser yang lebih robust
        rows.clear()
        currentRow = mutableListOf()
        field = StringBuilder()
        inQuotes = false
        var i = 0
        while (i < text.length) {
            val c = text[i]
            when {
                inQuotes -> {
                    when {
                        c == '"' && i + 1 < text.length && text[i + 1] == '"' -> {
                            // Escaped quote
                            field.append('"')
                            i += 2
                        }
                        c == '"' -> {
                            inQuotes = false
                            i++
                        }
                        else -> {
                            field.append(c)
                            i++
                        }
                    }
                }
                else -> {
                    when (c) {
                        '"' -> { inQuotes = true; i++ }
                        delimiter -> {
                            currentRow.add(field.toString())
                            field = StringBuilder()
                            i++
                        }
                        '\r' -> {
                            // CRLF atau LF
                            currentRow.add(field.toString())
                            field = StringBuilder()
                            rows.add(currentRow)
                            currentRow = mutableListOf()
                            if (i + 1 < text.length && text[i + 1] == '\n') i += 2 else i++
                        }
                        '\n' -> {
                            currentRow.add(field.toString())
                            field = StringBuilder()
                            rows.add(currentRow)
                            currentRow = mutableListOf()
                            i++
                        }
                        else -> { field.append(c); i++ }
                    }
                }
            }
        }
        // Last field/row jika tidak ada newline terakhir
        if (field.isNotEmpty() || currentRow.isNotEmpty()) {
            currentRow.add(field.toString())
            rows.add(currentRow)
        }
        return rows
    }

    private fun processLine(line: String, inQuotes: Boolean, field: StringBuilder,
                            currentRow: MutableList<String>, rows: MutableList<List<String>>) {
        // Helper tidak dipakai; implementasi utama di while loop
    }
}

// --- Type-safe mapper ---
class PegawaiMapper {
    fun fromRow(header: List<String>, row: List<String>): Pegawai {
        val idx = header.withIndex().associate { it.value to it.index }
        fun get(name: String): String = row[idx[name] ?: error("Kolom $name tidak ada")]
        return Pegawai(
            id = get("id").toLong(),
            nama = get("nama"),
            departemen = get("departemen"),
            gaji = get("gaji").toLong(),
            aktif = get("aktif").toBoolean()
        )
    }

    fun toRow(p: Pegawai): List<String> {
        return listOf(p.id.toString(), p.nama, p.departemen, p.gaji.toString(), p.aktif.toString())
    }

    fun header(): List<String> = listOf("id", "nama", "departemen", "gaji", "aktif")
}

// --- Streaming reader untuk file besar ---
class StreamingCsvReader(private val reader: BufferedReader) {
    private val delimiter: Char = ','

    fun forEachRow(onRow: (List<String>) -> Unit) {
        var line: String? = reader.readLine()
        while (line != null) {
            // Handle multiline quoted field
            val sb = StringBuilder(line)
            while (sb.count { it == '"' } % 2 != 0) {
                val next = reader.readLine() ?: break
                sb.append('\n').append(next)
            }
            onRow(parseLine(sb.toString()))
            line = reader.readLine()
        }
        reader.close()
    }

    private fun parseLine(line: String): List<String> {
        val fields = mutableListOf<String>()
        val field = StringBuilder()
        var inQuotes = false
        var i = 0
        while (i < line.length) {
            val c = line[i]
            when {
                inQuotes -> {
                    when {
                        c == '"' && i + 1 < line.length && line[i + 1] == '"' -> {
                            field.append('"'); i += 2
                        }
                        c == '"' -> { inQuotes = false; i++ }
                        else -> { field.append(c); i++ }
                    }
                }
                else -> {
                    when (c) {
                        '"' -> { inQuotes = true; i++ }
                        delimiter -> {
                            fields.add(field.toString())
                            field.setLength(0); i++
                        }
                        else -> { field.append(c); i++ }
                    }
                }
            }
        }
        fields.add(field.toString())
        return fields
    }
}

// --- Demo ---

fun main() {
    println("=== CSV Processing ===\n")

    // Buat data CSV
    val pegawai = listOf(
        Pegawai(1, "Budi Santoso", "Engineering", 15_000_000L, true),
        Pegawai(2, "Siti Aprilia", "Marketing", 12_500_000L, true),
        Pegawai(3, "Dewi Lestari", "Engineering", 18_000_000L, true),
        Pegawai(4, "Andi Pratama, S.T.", "HR", 10_000_000L, false),  // nama mengandung koma
        Pegawai(5, "Citra \"Cici\" Wijaya", "Finance", 14_000_000L, true)  // nama mengandung kutip
    )

    val mapper = PegawaiMapper()
    val header = mapper.header()

    println("--- Tulis CSV ---")
    val rows = pegawai.map { mapper.toRow(it) }
    val csvText = CsvWriter.writeAll(rows, header)
    println(csvText)

    println("--- Parse CSV (round-trip) ---")
    val reader = CsvReader()
    val parsed = reader.parse(csvText)
    val parsedHeader = parsed.first()
    val parsedRows = parsed.drop(1)
    println("Header : $parsedHeader")
    println("Jumlah row: ${parsedRows.size}")
    println()

    println("--- Type-safe mapping ke Pegawai ---")
    val pegawaiRestored = parsedRows.map { mapper.fromRow(parsedHeader, it) }
    for (p in pegawaiRestored) {
        println("  $p")
    }
    println("Round-trip identik: ${pegawaiRestored == pegawai}")
    println()

    println("--- Streaming reader untuk file besar ---")
    // Simulasi: hitung total gaji pegawai aktif
    val stringReader = BufferedReader(StringReader(csvText))
    val streamReader = StreamingCsvReader(stringReader)
    var totalGaji = 0L
    var jumlahAktif = 0
    var isFirstRow = true
    var streamHeader: List<String> = emptyList()
    streamReader.forEachRow { row ->
        if (isFirstRow) {
            streamHeader = row
            isFirstRow = false
        } else {
            val p = mapper.fromRow(streamHeader, row)
            if (p.aktif) {
                totalGaji += p.gaji
                jumlahAktif++
            }
        }
    }
    println("Total gaji pegawai aktif: $totalGaji")
    println("Jumlah pegawai aktif  : $jumlahAktif")
    println()

    println("--- Bahaya parsing CSV manual ---")
    println("1. Field dengan koma: Budi, S.T. -> harus dibungkus kutip")
    println("2. Field dengan kutip: \"Cici\" -> escape dengan \"\"")
    println("3. Field dengan newline: harus pakai streaming reader")
    println("4. Encoding: gunakan UTF-8; perhatikan BOM pada file Windows")
    println("5. Header tidak konsisten: cek nama kolom sebelum mapping")
    println()

    println("--- Library produksi untuk CSV ---")
    println("- kotlinx-csv: pure Kotlin, KMP-compatible")
    println("- commons-csv (Apache): mature, banyak fitur")
    println("- opencsv: integrasi dengan Jackson dan beans")
}

Output yang Dihasilkan:

text
=== CSV Processing ===

--- Tulis CSV ---
id,nama,departemen,gaji,aktif
1,Budi Santoso,Engineering,15000000,true
2,Siti Aprilia,Marketing,12500000,true
3,Dewi Lestari,Engineering,18000000,true
4,"Andi Pratama, S.T.",HR,10000000,false
5,"Citra ""Cici"" Wijaya",Finance,14000000,true

--- Parse CSV (round-trip) ---
Header : [id, nama, departemen, gaji, aktif]
Jumlah row: 5

--- Type-safe mapping ke Pegawai ---
  Pegawai(id=1, nama=Budi Santoso, departemen=Engineering, gaji=15000000, aktif=true)
  Pegawai(id=2, nama=Siti Aprilia, departemen=Marketing, gaji=12500000, aktif=true)
  Pegawai(id=3, nama=Dewi Lestari, departemen=Engineering, gaji=18000000, aktif=true)
  Pegawai(id=4, nama=Andi Pratama, S.T., departemen=HR, gaji=10000000, aktif=false)
  Pegawai(id=5, nama=Citra "Cici" Wijaya, departemen=Finance, gaji=14000000, aktif=true)
Round-trip identik: true

--- Streaming reader untuk file besar ---
Total gaji pegawai aktif: 59500000
Jumlah pegawai aktif  : 4

--- Bahaya parsing CSV manual ---
1. Field dengan koma: Budi, S.T. -> harus dibungkus kutip
2. Field dengan kutip: "Cici" -> escape dengan ""
3. Field dengan newline: harus pakai streaming reader
4. Encoding: gunakan UTF-8; perhatikan BOM pada file Windows
5. Header tidak konsisten: cek nama kolom sebelum mapping

--- Library produksi untuk CSV ---
- kotlinx-csv: pure Kotlin, KMP-compatible
- commons-csv (Apache): mature, banyak fitur
- opencsv: integrasi dengan Jackson dan beans

Mari kita bedah bagian kunci dari kode di atas:

  1. CsvWriter.escape(s): menulis field sesuai RFC 4180. Bila field mengandung koma, kutip, atau newline, seluruh field dibungkus tanda kutip. Tanda kutip di dalam field di-escape dengan dua kutip ("").
  2. CsvWriter.writeAll(rows, header): menulis header (opsional) lalu setiap row. Setiap row adalah List<String>—list field dalam urutan kolom.
  3. CsvReader.parse(text): parser state machine char-by-char. State inQuotes menentukan apakah koma dan newline diperlakukan sebagai pemisah atau bagian field. Saat inQuotes=true, dua kutip berurutan ("") di-escape menjadi satu kutip.
  4. PegawaiMapper.fromRow(header, row): type-safe mapping. Header di-mapping ke index kolom; row di-mapping ke Pegawai dengan konversi tipe—toLong(), toBoolean(). Bila kolom wajib hilang, exception dilempar.
  5. PegawaiMapper.toRow(p): kebalikan—mengubah Pegawai menjadi List<String> siap ditulis. Field dikonversi ke string; pemformatan angka sesuai default toString().
  6. StreamingCsvReader.forEachRow(onRow): streaming reader untuk file besar. BufferedReader membaca baris per baris; bila jumlah kutip ganjil, baca baris berikutnya sampai jumlah genap (menangani field yang berisi newline). Callback onRow dipanggil untuk setiap row—tidak semua row disimpan di memori.
  7. Output menunjukkan round-trip identik: lima pegawai (termasuk yang punya koma dan kutip di nama) di-serialize ke CSV, lalu di-parse dan di-map kembali—hasilnya identik dengan aslinya. Streaming reader menghitung total gaji pegawai aktif tanpa memuat seluruh CSV ke memori.

Bahaya Parser Naif #

Parser naif yang memakai line.split(",") akan rusak pada banyak kasus. Field "Andi Pratama, S.T." akan di-split menjadi dua bagian karena koma di dalam kutip ikut terhitung. Field multiline (yang berisi \n di dalam kutip) tidak dapat diparse per-baris—diperlukan buffering sampai jumlah kutip genap.

kotlin Parser naif yang salah
val fields = line.split(",")  // SALAH untuk "Budi, S.T."
// Hasil: ["\"Budi", " S.T.\""]  -- bukan satu field

Selalu pakai parser yang memahami state machine RFC 4180, baik manual (seperti demo) atau library produksi.

Library Produksi CSV #

Untuk produksi, library berikut populer di JVM/Kotlin:

LibraryBahasaFitur Utama
kotlinx-csvKotlin (KMP)Pure Kotlin, mendukung streaming
commons-csv (Apache)JavaMature, banyak konfigurasi
opencsvJavaIntegrasi bean mapping
jackson-dataformat-csvJavaIntegrasi Jackson ecosystem

kotlinx-csv adalah pilihan terbaik untuk proyek KMP karena pure Kotlin dan mendukung streaming reader untuk file besar.

Referensi Lanjutan #

  • Serialization — dokumentasi kotlinx.serialization yang menjelaskan format kustom seperti CSV

Penutup #

CSV processing yang benar memerlukan kepatuhan RFC 4180—escaping koma, kutip, dan newline. Parser state machine char-by-char menangani semua kasus; parser naif split(",") akan rusak. Type-safe mapping mengubah row menjadi data class dengan konversi tipe. Streaming reader memungkinkan pemrosesan file besar tanpa memori berlebih. Chapter berikutnya akan masuk ke XML—format yang lebih kompleks dengan struktur tree, atribut, dan namespace.