CSV to SQL Importer — Kotlin source
Turn CSV data into SQL import statements: batched multi-row INSERTs, a Postgres COPY FROM STDIN block, or a MySQL LOAD DATA statement. Infers numeric columns, emits NULL for empty fields, sanitizes and de-duplicates header names into SQL identifiers.
This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.
// csv-to-sql — pure CSV → SQL import generator. Kotlin port (canonical TS:
// src/lib/csv-to-sql.ts; Go twin: cli/csv-to-sql). RFC 4180 parse, header
// sanitizing into SQL identifiers, then batched INSERTs, a Postgres COPY
// block, or a MySQL LOAD DATA statement. Numeric-looking text emits bare and
// verbatim; empty fields become NULL.
/** RFC 4180 parser mirroring the TS loop: lenient quotes, CR dropped, a
* trailing field without a newline still completes its row. */
fun csvToRows(csv: String): List<List<String>> {
val rows = mutableListOf<List<String>>()
val field = StringBuilder()
var row = mutableListOf<String>()
var inQ = false
var i = 0
while (i < csv.length) {
val ch = csv[i]
when {
inQ -> when (ch) {
'"' -> if (i + 1 < csv.length && csv[i + 1] == '"') { field.append('"'); i++ } else inQ = false
else -> field.append(ch)
}
ch == '"' -> inQ = true
ch == ',' -> { row.add(field.toString()); field.setLength(0) }
ch == '\n' -> {
row.add(field.toString()); rows.add(row); row = mutableListOf(); field.setLength(0)
}
ch != '\r' -> field.append(ch)
}
i++
}
if (field.isNotEmpty() || row.isNotEmpty()) { row.add(field.toString()); rows.add(row) }
return rows
}
private val NUMERIC = Regex("""^-?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$""")
private val IDENT = Regex("[^A-Za-z0-9_]")
fun escapeSqlString(s: String, dialect: String = "standard"): String {
var out = s.replace("'", "''")
if (dialect == "mysql") {
out = out.replace("\\", "\\\\").replace("\u0000", "\\0")
.replace("\n", "\\n").replace("\r", "\\r").replace("\u001A", "\\Z")
}
return out
}
fun fieldLiteral(value: String, dialect: String = "standard", inferTypes: Boolean = true): String {
if (inferTypes) {
if (value.isEmpty()) return "NULL"
if (NUMERIC.matches(value)) return value // verbatim — no float round-trip
}
return "'${escapeSqlString(value, dialect)}'"
}
fun sanitizeIdent(name: String): String = IDENT.replace(name, "_")
private fun sanitizeHeaders(headers: List<String>): List<String> {
val seen = mutableMapOf<String, Int>()
return headers.mapIndexed { i, h ->
var id = sanitizeIdent(h.trim())
if (id.isEmpty()) id = "col${i + 1}"
val n = (seen[id] ?: 0) + 1
seen[id] = n
if (n > 1) "${id}_$n" else id
}
}
private fun csvEscape(field: String): String =
if (field.any { it == ',' || it == '"' || it == '\n' || it == '\r' })
"\"" + field.replace("\"", "\"\"") + "\""
else field
data class Options(
val table: String = "",
val format: String = "insert", // insert | copy | load-data
val dialect: String = "standard", // standard | mysql | postgres
val batchSize: Int = 100,
val inferTypes: Boolean = true,
val quoteIdentifiers: Boolean = true,
val fileName: String = "import.csv",
)
data class Result(val ok: Boolean, val sql: String, val rows: Int, val error: String?)
private fun quoteIdent(name: String, mysql: Boolean, quoteIds: Boolean): String =
when {
!quoteIds -> name
mysql -> "`$name`"
else -> "\"$name\""
}
fun csvToSql(csv: String, opts: Options): Result {
val text = csv.trim()
val rows = csvToRows(text)
if (text.isEmpty() || rows.size < 2) return Result(false, "", 0, "No rows to import.")
val cols = sanitizeHeaders(rows[0])
val data = rows.drop(1)
val mysql = opts.dialect == "mysql"
val identMysql = when (opts.format) {
"load-data" -> true
"copy" -> false
else -> mysql
}
val tblRaw = sanitizeIdent(opts.table)
val tbl = quoteIdent(tblRaw.ifEmpty { "tbl" }, identMysql, opts.quoteIdentifiers)
val colList = cols.joinToString(", ") { quoteIdent(it, identMysql, opts.quoteIdentifiers) }
fun cellAt(r: List<String>, ci: Int) = if (ci < r.size) r[ci] else ""
if (opts.format == "copy" || opts.format == "load-data") {
val payload = mutableListOf(cols.joinToString(",") { csvEscape(it) })
data.forEach { r -> payload.add(cols.indices.joinToString(",") { ci -> csvEscape(cellAt(r, ci)) }) }
val body = payload.joinToString("\n")
return if (opts.format == "copy") {
Result(true, "COPY $tbl ($colList) FROM STDIN WITH (FORMAT csv, HEADER true);\n$body\n\\.", data.size, null)
} else {
val clean = opts.fileName.filter { it in 'A'..'Z' || it in 'a'..'z' || it in '0'..'9' || it == '.' || it == '_' || it == '-' || it == '/' }
val file = clean.ifEmpty { "import.csv" }
Result(true, "LOAD DATA LOCAL INFILE '$file'\nINTO TABLE $tbl\n" +
"FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"'\n" +
"LINES TERMINATED BY '\\n'\nIGNORE 1 LINES;\n\n$body", data.size, null)
}
}
val size = maxOf(1, opts.batchSize)
val stmts = data.chunked(size).map { chunk ->
val values = chunk.map { r ->
" (" + cols.indices.joinToString(", ") { ci -> fieldLiteral(cellAt(r, ci), opts.dialect, opts.inferTypes) } + ")"
}
"INSERT INTO $tbl ($colList) VALUES\n" + values.joinToString(",\n") + ";"
}
return Result(true, stmts.joinToString("\n"), data.size, null)
}
// Example:
// val r = csvToSql("id,name\n1,Ada\n2,", Options(table = "users"))
// println(r.sql)
// // INSERT INTO "users" ("id", "name") VALUES
// // (1, 'Ada'),
// // (2, NULL);
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →