CSV to SQL Importer — Go 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 Go implementation — the same logic the interactive tool runs, in a shareable, citable form.
// Package csvtosql is the Go twin of CosmoDev's src/lib/csv-to-sql.ts (dual
// source: the web lib is TypeScript, the CLI lib is Go — kept in lock-step).
// Pure + deterministic, never panics. The table-driven tests in
// csv-to-sql_test.go share vectors with src/lib/csv-to-sql.test.ts so the two
// implementations are held to the same contract.
//
// The converter mirrors the TS lib exactly: parse RFC 4180 CSV with the same
// hand-rolled loop as src/lib/csv.ts (csvToRows — ported verbatim, not via
// encoding/csv, whose lazy-quote and CR semantics differ), sanitize header
// names into SQL identifiers (de-duplicated deterministically), then emit one
// of three formats: batched multi-row INSERT statements (dialect-aware
// escaping + identifier quoting), a Postgres COPY FROM STDIN block, or a
// MySQL LOAD DATA statement with the CSV payload. Numeric-looking fields are
// emitted VERBATIM (no float parsing or reformatting — "007" stays "007",
// "1e3" stays "1e3"), so TypeScript and Go output is byte-identical. Empty
// fields become NULL under type inference.
package csvtosql
import (
"regexp"
"strings"
)
// Dialect selects identifier quoting and string escaping for INSERT output.
type Dialect string
const (
DialectStandard Dialect = "standard"
DialectMysql Dialect = "mysql"
DialectPostgres Dialect = "postgres"
)
// Format selects the emitted SQL shape.
type Format string
const (
FormatInsert Format = "insert"
FormatCopy Format = "copy"
FormatLoadData Format = "load-data"
)
// Options configures CsvToSql. Table is required. Zero values resolve to the
// TS defaults: insert format, standard dialect, batch 100, inference on,
// identifiers quoted, file name "import.csv". The two booleans are pointers
// so the zero value can mean "unset" (nil → default true), mirroring the TS
// optional fields.
type Options struct {
Table string
Format Format
Dialect Dialect
BatchSize int
InferTypes *bool
QuoteIdentifiers *bool
FileName string
}
// Result mirrors the TS Result shape.
type Result struct {
Ok bool
Sql string
Rows int
Error string
}
// numericRe matches a numeric literal (integer, decimal, or scientific).
// The matched text is emitted verbatim — no float round-trip.
var numericRe = regexp.MustCompile(`^-?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$`)
func boolOr(p *bool, def bool) bool {
if p == nil {
return def
}
return *p
}
// csvToRows is a verbatim port of csvToRows in src/lib/csv.ts: RFC 4180
// parser with the same leniency (bare quotes toggle quoted mode, CR is
// dropped, a trailing field without a newline still completes the row).
func csvToRows(csv string) [][]string {
var rows [][]string
field := strings.Builder{}
var row []string
inQ := false
runes := []rune(csv)
for i := 0; i < len(runes); i++ {
ch := runes[i]
switch {
case inQ:
if ch == '"' {
if i+1 < len(runes) && runes[i+1] == '"' {
field.WriteRune('"')
i++
} else {
inQ = false
}
} else {
field.WriteRune(ch)
}
case ch == '"':
inQ = true
case ch == ',':
row = append(row, field.String())
field.Reset()
case ch == '\n':
row = append(row, field.String())
rows = append(rows, row)
row = nil
field.Reset()
case ch != '\r':
field.WriteRune(ch)
}
}
if field.Len() > 0 || row != nil {
row = append(row, field.String())
rows = append(rows, row)
}
return rows
}
// EscapeSqlString escapes a single-quoted SQL literal: '' doubling
// everywhere; MySQL mode additionally escapes the backslash and the control
// bytes \0 \n \r \x1a. Same contract as jsonToSql's escapeSqlString.
func EscapeSqlString(s string, dialect Dialect) string {
out := strings.ReplaceAll(s, "'", "''")
if dialect == DialectMysql {
out = strings.ReplaceAll(out, `\`, `\\`)
out = strings.ReplaceAll(out, "\x00", `\0`)
out = strings.ReplaceAll(out, "\n", `\n`)
out = strings.ReplaceAll(out, "\r", `\r`)
out = strings.ReplaceAll(out, "\x1a", `\Z`)
}
return out
}
// FieldLiteral renders one CSV field as an INSERT value literal.
func FieldLiteral(value string, dialect Dialect, inferTypes bool) string {
if inferTypes {
if value == "" {
return "NULL"
}
if numericRe.MatchString(value) {
return value
}
}
return "'" + EscapeSqlString(value, dialect) + "'"
}
// SanitizeIdent replaces characters SQL identifiers cannot carry with '_'.
func SanitizeIdent(name string) string {
var b strings.Builder
for _, r := range name {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' {
b.WriteRune(r)
} else {
b.WriteRune('_')
}
}
return b.String()
}
func quoteIdent(name string, dialect Dialect, quoteIdentifiers bool) string {
if !quoteIdentifiers {
return name
}
if dialect == DialectMysql {
return "`" + name + "`"
}
return `"` + name + `"`
}
// sanitizeHeaders maps a header row to SQL column names: trim, sanitize,
// empty → col<N> (1-based), duplicates get _2, _3… suffixes in first-seen
// order.
func sanitizeHeaders(headers []string) []string {
out := make([]string, 0, len(headers))
seen := map[string]int{}
for i, h := range headers {
id := SanitizeIdent(strings.TrimSpace(h))
if id == "" {
id = "col" + itoa(i+1)
}
seen[id]++
if n := seen[id]; n > 1 {
id = id + "_" + itoa(n)
}
out = append(out, id)
}
return out
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// csvEscape quotes a CSV field for re-emission (RFC 4180).
func csvEscape(field string) string {
if strings.ContainsAny(field, ",\n\r\"") {
return `"` + strings.ReplaceAll(field, `"`, `""`) + `"`
}
return field
}
// sanitizeFileName keeps path-safe characters for the LOAD DATA reference.
func sanitizeFileName(name string) string {
var b strings.Builder
for _, r := range name {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') ||
r == '.' || r == '_' || r == '-' || r == '/' {
b.WriteRune(r)
}
}
if b.Len() == 0 {
return "import.csv"
}
return b.String()
}
// CsvToSql converts CSV text into SQL import statements. Never panics;
// errors come back through Result.
func CsvToSql(csv string, opts Options) Result {
fail := func(msg string) Result { return Result{Ok: false, Error: msg} }
text := strings.TrimSpace(csv)
if text == "" {
return fail("No rows to import.")
}
// csvToRows on trimmed non-empty text always yields >= 1 row.
rows := csvToRows(text)
headers := rows[0]
if len(rows) < 2 {
return fail("No data rows below the header.")
}
data := rows[1:]
format := opts.Format
if format == "" {
format = FormatInsert
}
dialect := opts.Dialect
if dialect == "" {
dialect = DialectStandard
}
inferTypes := boolOr(opts.InferTypes, true)
quoteIdentifiers := boolOr(opts.QuoteIdentifiers, true)
cols := sanitizeHeaders(headers)
// Quoting semantics follow the format: INSERT honors the dialect option,
// COPY is Postgres, LOAD DATA is MySQL.
identDialect := dialect
switch format {
case FormatCopy:
identDialect = DialectPostgres
case FormatLoadData:
identDialect = DialectMysql
}
tableIdent := SanitizeIdent(opts.Table)
if tableIdent == "" {
tableIdent = "tbl"
}
table := quoteIdent(tableIdent, identDialect, quoteIdentifiers)
quotedCols := make([]string, len(cols))
for i, c := range cols {
quotedCols[i] = quoteIdent(c, identDialect, quoteIdentifiers)
}
colList := strings.Join(quotedCols, ", ")
if format == FormatCopy || format == FormatLoadData {
payload := make([]string, 0, len(data)+1)
escapedCols := make([]string, len(cols))
for i, c := range cols {
escapedCols[i] = csvEscape(c)
}
payload = append(payload, strings.Join(escapedCols, ","))
for _, row := range data {
fields := make([]string, len(cols))
for ci := range cols {
v := ""
if ci < len(row) {
v = row[ci]
}
fields[ci] = csvEscape(v)
}
payload = append(payload, strings.Join(fields, ","))
}
body := strings.Join(payload, "\n")
if format == FormatCopy {
sql := "COPY " + table + " (" + colList + ") FROM STDIN WITH (FORMAT csv, HEADER true);\n" +
body + "\n\\."
return Result{Ok: true, Sql: sql, Rows: len(data)}
}
sql := "LOAD DATA LOCAL INFILE '" + sanitizeFileName(opts.FileName) + "'\n" +
"INTO TABLE " + table + "\n" +
"FIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"'\n" +
"LINES TERMINATED BY '\\n'\n" +
"IGNORE 1 LINES;\n\n" + body
return Result{Ok: true, Sql: sql, Rows: len(data)}
}
// INSERT: batched multi-row statements.
batchSize := opts.BatchSize
if batchSize <= 0 {
batchSize = 100
}
var stmts []string
for start := 0; start < len(data); start += batchSize {
end := min(start+batchSize, len(data))
var values []string
for _, row := range data[start:end] {
vals := make([]string, len(cols))
for ci := range cols {
v := ""
if ci < len(row) {
v = row[ci]
}
vals[ci] = FieldLiteral(v, dialect, inferTypes)
}
values = append(values, " ("+strings.Join(vals, ", ")+")")
}
stmts = append(stmts, "INSERT INTO "+table+" ("+colList+") VALUES\n"+strings.Join(values, ",\n")+";")
}
return Result{Ok: true, Sql: strings.Join(stmts, "\n"), Rows: len(data)}
}
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 →