CSV to SQL Importer — Swift 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 Swift implementation — the same logic the interactive tool runs, in a shareable, citable form.
// csv-to-sql — pure CSV → SQL import generator. Swift 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.
import Foundation
/// RFC 4180 parser mirroring the TS loop: lenient quotes, CR dropped, a
/// trailing field without a newline still completes its row.
func csvToRows(_ csv: String) -> [[String]] {
var rows: [[String]] = []
var field = ""
var row: [String] = []
var inQ = false
let chars = Array(csv)
var i = 0
while i < chars.count {
let ch = chars[i]
if inQ {
if ch == "\"" {
if i + 1 < chars.count && chars[i + 1] == "\"" { field.append("\""); i += 1 }
else { inQ = false }
} else {
field.append(ch)
}
} else if ch == "\"" {
inQ = true
} else if ch == "," {
row.append(field); field = ""
} else if ch == "\n" {
row.append(field); rows.append(row); row = []; field = ""
} else if ch != "\r" {
field.append(ch)
}
i += 1
}
if !field.isEmpty || !row.isEmpty { row.append(field); rows.append(row) }
return rows
}
func isIdentChar(_ c: Character) -> Bool {
(c >= "A" && c <= "Z") || (c >= "a" && c <= "z") || (c >= "0" && c <= "9") || c == "_"
}
/// Verbatim numeric literal: -?(digits[.digits] | .digits)(e[+-]?digits)?
/// "5." is rejected (digits required after the dot) — matches the TS regex.
func isNumeric(_ s: String) -> Bool {
let chars = Array(s)
var i = 0
let n = chars.count
if i < n && chars[i] == "-" { i += 1 }
let intStart = i
while i < n && chars[i] >= "0" && chars[i] <= "9" { i += 1 }
let hadInt = i > intStart
var hadFrac = false
var sawDot = false
if i < n && chars[i] == "." {
sawDot = true; i += 1
let fs = i
while i < n && chars[i] >= "0" && chars[i] <= "9" { i += 1 }
hadFrac = i > fs
}
if sawDot && !hadFrac { return false }
if !hadInt && !hadFrac { return false }
if i < n && (chars[i] == "e" || chars[i] == "E") {
i += 1
if i < n && (chars[i] == "+" || chars[i] == "-") { i += 1 }
let es = i
while i < n && chars[i] >= "0" && chars[i] <= "9" { i += 1 }
if i == es { return false }
}
return i == n
}
func escapeSqlString(_ s: String, dialect: String = "standard") -> String {
var out = s.replacingOccurrences(of: "'", with: "''")
if dialect == "mysql" {
out = out.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\0", with: "\\0")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
.replacingOccurrences(of: "\u{1a}", with: "\\Z")
}
return out
}
func fieldLiteral(_ value: String, dialect: String = "standard", inferTypes: Bool = true) -> String {
if inferTypes {
if value.isEmpty { return "NULL" }
if isNumeric(value) { return value } // verbatim — no float round-trip
}
return "'\(escapeSqlString(value, dialect: dialect))'"
}
func sanitizeIdent(_ name: String) -> String {
String(name.map { isIdentChar($0) ? $0 : "_" })
}
func sanitizeHeaders(_ headers: [String]) -> [String] {
var seen: [String: Int] = [:]
return headers.enumerated().map { i, h in
var id = sanitizeIdent(h.trimmingCharacters(in: .whitespaces))
if id.isEmpty { id = "col\(i + 1)" }
seen[id, default: 0] += 1
let n = seen[id]!
return n > 1 ? "\(id)_\(n)" : id
}
}
func csvEscape(_ field: String) -> String {
let needs = field.contains(",") || field.contains("\"") ||
field.contains("\n") || field.contains("\r")
return needs ? "\"" + field.replacingOccurrences(of: "\"", with: "\"\"") + "\"" : field
}
public struct Options {
public var table: String = ""
public var format: String = "insert" // insert | copy | load-data
public var dialect: String = "standard" // standard | mysql | postgres
public var batchSize: Int = 100
public var inferTypes: Bool = true
public var quoteIdentifiers: Bool = true
public var fileName: String = "import.csv"
public init() {}
}
public struct Result {
public var ok = false
public var sql = ""
public var rows = 0
public var error: String?
}
func quoteIdent(_ name: String, mysql: Bool, quoteIds: Bool) -> String {
if !quoteIds { return name }
return mysql ? "`\(name)`" : "\"\(name)\""
}
public func csvToSql(_ csv: String, _ opts: Options) -> Result {
let text = csv.trimmingCharacters(in: .whitespacesAndNewlines)
let rows = csvToRows(text)
if text.isEmpty || rows.count < 2 {
return Result(ok: false, sql: "", rows: 0, error: "No rows to import.")
}
let cols = sanitizeHeaders(rows[0])
let data = Array(rows.dropFirst())
let mysql = opts.dialect == "mysql"
let identMysql = opts.format == "load-data" ? true
: opts.format == "copy" ? false
: mysql
let tblRaw = sanitizeIdent(opts.table)
let tbl = quoteIdent(tblRaw.isEmpty ? "tbl" : tblRaw, mysql: identMysql, quoteIds: opts.quoteIdentifiers)
let colList = cols.map { quoteIdent($0, mysql: identMysql, quoteIds: opts.quoteIdentifiers) }.joined(separator: ", ")
func cellAt(_ r: [String], _ ci: Int) -> String { ci < r.count ? r[ci] : "" }
if opts.format == "copy" || opts.format == "load-data" {
var payload = [cols.map(csvEscape).joined(separator: ",")]
payload += data.map { r in cols.indices.map { csvEscape(cellAt(r, $0)) }.joined(separator: ",") }
let body = payload.joined(separator: "\n")
if opts.format == "copy" {
return Result(ok: true, sql: "COPY \(tbl) (\(colList)) FROM STDIN WITH (FORMAT csv, HEADER true);\n\(body)\n\\.", rows: data.count, error: nil)
}
let clean = String(opts.fileName.filter { isIdentChar($0) || $0 == "." || $0 == "-" || $0 == "/" })
let file = clean.isEmpty ? "import.csv" : clean
return Result(ok: true, sql: "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)", rows: data.count, error: nil)
}
let size = max(1, opts.batchSize)
var stmts: [String] = []
var start = 0
while start < data.count {
let end = min(data.count, start + size)
let values = (start..<end).map { r in
" (" + cols.indices.map { ci in
fieldLiteral(cellAt(data[r], ci), dialect: opts.dialect, inferTypes: opts.inferTypes)
}.joined(separator: ", ") + ")"
}
stmts.append("INSERT INTO \(tbl) (\(colList)) VALUES\n" + values.joined(separator: ",\n") + ";")
start += size
}
return Result(ok: true, sql: stmts.joined(separator: "\n"), rows: data.count, error: nil)
}
// Example:
// var o = Options()
// o.table = "users"
// let r = csvToSql("id,name\n1,Ada\n2,", o)
// print(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 →