Skip to content

JSON to SQL INSERT — Kotlin source

Convert a JSON array of objects into SQL INSERT statements. Properly escapes strings, handles nulls, booleans, numbers, nested objects, and multi-row inserts.

This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// json-to-sql — pure JSON → SQL INSERT converter. Kotlin port (canonical TS:
// src/lib/jsonToSql.ts; Go twin: cli/json-to-sql). The JVM stdlib has no JSON
// codec, so — like the Rust port — this carries a small hand-rolled JSON DOM
// parser: LinkedHashMap keeps first-seen key order and number tokens stay raw
// text, so column unions and literals match the TS reference. Errors return
// through the Res data class — never thrown on user input.
sealed class J {
    object Nul : J(); data class B(val v: Boolean) : J(); data class Num(val raw: String) : J()
    data class S(val s: String) : J()
    data class A(val items: List<J>) : J(); data class O(val pairs: LinkedHashMap<String, J>) : J()
}
class Parser(private val s: String) {
    private var i = 0
    private fun err(): Nothing = throw IllegalArgumentException("Invalid JSON")
    private fun peek() = if (i < s.length) s[i] else '\u0000' // '\u0000' sentinel: never a number char
    private fun take(): Char { if (i >= s.length) err(); return s[i++] }
    private fun ws() { while (i < s.length && s[i].isWhitespace()) i++ }
    fun parse(): J { val v = value(); ws(); if (i != s.length) err(); return v }
    private fun value(): J { ws(); return when (val c = peek()) {
        '{' -> { i++; val m = LinkedHashMap<String, J>(); ws()
            if (peek() == '}') { i++; return J.O(m) }
            while (true) { ws(); val k = (value() as J.S).s; ws(); if (take() != ':') err()
                m[k] = value(); ws(); val d = take(); if (d == '}') break; if (d != ',') err() }
            J.O(m) }
        '[' -> { i++; val l = mutableListOf<J>(); ws()
            if (peek() == ']') { i++; return J.A(l) }
            while (true) { l.add(value()); ws(); val d = take(); if (d == ']') break; if (d != ',') err() }
            J.A(l) }
        '"' -> J.S(str())
        'n' -> { i += 4; J.Nul }; 't' -> { i += 4; J.B(true) }; 'f' -> { i += 5; J.B(false) }
        else -> J.Num(num())
    } }
    private fun str(): String { if (take() != '"') err(); val b = StringBuilder(); while (true) { when (val c = take()) {
        '"' -> return b.toString()
        '\\' -> when (val e = take()) {
            'b' -> b.append('\b'); 'f' -> b.append('\u000C'); 'n' -> b.append('\n'); 'r' -> b.append('\r'); 't' -> b.append('\t')
            'u' -> { var cp = 0; repeat(4) { cp = cp * 16 + take().digitToInt(16) }; b.append(cp.toChar()) }
            else -> b.append(e) } // '"', '\\', '/' pass through
        else -> b.append(c) } } }
    private fun num(): String { val from = i
        while ("-+.eE0123456789".contains(peek())) i++
        if (i == from) err(); return s.substring(from, i) } // raw token, String(n)-safe
}
data class Res(val ok: Boolean, val sql: String, val rows: Int, val error: String?)
fun escapeSqlString(s: String, mysql: Boolean): String = buildString {
    for (c in s) when {
        c == '\'' -> append("''")
        mysql && c == '\\' -> append("\\\\")
        mysql && c == '\u0000' -> append("\\0") // NUL
        mysql && c == '\n' -> append("\\n")
        mysql && c == '\r' -> append("\\r")
        mysql && c == '\u001a' -> append("\\Z") // SUB — ends input on some MySQL clients
        else -> append(c) } }
fun sqlLiteral(v: J, mysql: Boolean): String = when (v) {
    is J.Nul -> "NULL"
    is J.B -> if (mysql) (if (v.v) "1" else "0") else (if (v.v) "TRUE" else "FALSE")
    is J.Num -> v.raw; is J.S -> "'${escapeSqlString(v.s, mysql)}'"
    is J.A, is J.O -> "'${escapeSqlString(compact(v), mysql)}'" } // object/array → JSON text
fun compact(v: J): String = when (v) { // JSON.stringify-style re-emission
    is J.Nul -> "null"; is J.B -> v.v.toString(); is J.Num -> v.raw
    is J.S -> "\"" + v.s.replace("\\", "\\\\").replace("\"", "\\\"")
        .replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t") + "\""
    is J.A -> v.items.joinToString(",", "[", "]") { compact(it) }
    is J.O -> v.pairs.entries.joinToString(",", "{", "}") { compact(J.S(it.key)) + ":" + compact(it.value) } }
fun quoteIdent(n: String, mysql: Boolean, quote: Boolean) = if (!quote) n else if (mysql) "`$n`" else "\"$n\""
fun sanitizeIdent(n: String): String = n.replace(Regex("[^A-Za-z0-9_]"), "_").ifEmpty { "tbl" }
/** Convert a JSON document into a single multi-row INSERT. A bare object is one
 *  row; an array is many. Heterogeneous rows unify via first-seen columns. */
fun jsonToInsert(json: String, table: String, mysql: Boolean = false, quote: Boolean = true): Res {
    val root = try { Parser(json).parse() } catch (e: IllegalArgumentException) { return Res(false, "", 0, e.message) }
    val rows: List<J> = if (root is J.A) root.items else listOf(root)
    if (rows.isEmpty()) return Res(false, "", 0, "No rows to insert.")
    if (rows.any { it !is J.O }) return Res(false, "", 0, "Rows must be objects.")
    val cols = mutableListOf<String>() // first-seen union of every row's keys
    for (r in rows) for (k in (r as J.O).pairs.keys) if (k !in cols) cols.add(k)
    val tbl = quoteIdent(sanitizeIdent(table), mysql, quote)
    val colList = cols.joinToString(", ") { quoteIdent(it, mysql, quote) }
    val lines = rows.map { r -> val m = (r as J.O).pairs
        "  (" + cols.joinToString(", ") { sqlLiteral(m[it] ?: J.Nul, mysql) } + ")" }
    return Res(true, "INSERT INTO $tbl ($colList) VALUES\n" + lines.joinToString(",\n") + ";", rows.size, null)
}
fun main() {
    println(jsonToInsert("[{\"id\":1,\"name\":\"O'Hara\"},{\"id\":2,\"age\":30.5,\"tags\":[\"a\",\"b\"]}]", "users").sql)
    // INSERT INTO "users" ("id", "name", "age", "tags") VALUES ...
}

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 →