CSV to SQL Importer — Rust 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 Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! csv-to-sql — pure CSV → SQL import generator. Rust 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. No external crates — mirrors the TS
//! loop directly so all ports stay byte-identical.
/// RFC 4180 parser mirroring the TS loop: lenient quotes, CR dropped, a
/// trailing field without a newline still completes its row.
pub fn csv_to_rows(csv: &str) -> Vec<Vec<String>> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut field = String::new();
let mut row: Vec<String> = Vec::new();
let mut in_q = false;
let chars: Vec<char> = csv.chars().collect();
let n = chars.len();
let mut i = 0;
while i < n {
let ch = chars[i];
if in_q {
if ch == '"' {
if i + 1 < n && chars[i + 1] == '"' {
field.push('"');
i += 1;
} else {
in_q = false;
}
} else {
field.push(ch);
}
} else if ch == '"' {
in_q = true;
} else if ch == ',' {
row.push(std::mem::take(&mut field));
} else if ch == '\n' {
row.push(std::mem::take(&mut field));
rows.push(std::mem::take(&mut row));
} else if ch != '\r' {
field.push(ch);
}
i += 1;
}
if !field.is_empty() || !row.is_empty() {
row.push(field);
rows.push(row);
}
rows
}
pub fn escape_sql_string(s: &str, dialect: &str) -> String {
let mut out = s.replace('\'', "''");
if dialect == "mysql" {
out = out.replace('\\', "\\\\")
.replace('\0', "\\0")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\u{1a}', "\\Z");
}
out
}
fn is_numeric(value: &str) -> bool {
// Integer, decimal, or scientific literal — the matched text is emitted
// VERBATIM (no float round-trip, so "007" and "1e3" pass through as-is).
let b = value.as_bytes();
let mut i = 0;
if i < b.len() && b[i] == b'-' { i += 1; }
let int_start = i;
while i < b.len() && b[i].is_ascii_digit() { i += 1; }
let had_int = i > int_start;
let mut had_frac = false;
let mut saw_dot = false;
if i < b.len() && b[i] == b'.' {
saw_dot = true;
i += 1;
let fs = i;
while i < b.len() && b[i].is_ascii_digit() { i += 1; }
had_frac = i > fs;
}
// ".5" ok, "5." rejected (digits required after the dot) — matches TS.
if saw_dot && !had_frac { return false; }
if !had_int && !had_frac { return false; }
if i < b.len() && (b[i] == b'e' || b[i] == b'E') {
i += 1;
if i < b.len() && (b[i] == b'+' || b[i] == b'-') { i += 1; }
let es = i;
while i < b.len() && b[i].is_ascii_digit() { i += 1; }
if i == es { return false; }
}
i == b.len()
}
pub fn field_literal(value: &str, dialect: &str, infer_types: bool) -> String {
if infer_types {
if value.is_empty() { return "NULL".into(); }
if is_numeric(value) { return value.into(); }
}
format!("'{}'", escape_sql_string(value, dialect))
}
pub fn sanitize_ident(name: &str) -> String {
name.chars()
.map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
.collect()
}
fn sanitize_headers(headers: &[String]) -> Vec<String> {
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
headers
.iter()
.enumerate()
.map(|(i, h)| {
let mut id = sanitize_ident(h.trim());
if id.is_empty() {
id = format!("col{}", i + 1);
}
let n = seen.entry(id.clone()).or_insert(0);
*n += 1;
if *n > 1 {
id = format!("{}_{}", id, n);
}
id
})
.collect()
}
fn csv_escape(field: &str) -> String {
if field.contains(',') || field.contains('\n') || field.contains('\r') || field.contains('"') {
format!("\"{}\"", field.replace('"', "\"\""))
} else {
field.to_string()
}
}
pub struct Options<'a> {
pub table: &'a str,
pub format: &'a str, // "insert" | "copy" | "load-data"
pub dialect: &'a str, // "standard" | "mysql" | "postgres"
pub batch_size: usize,
pub infer_types: bool,
pub quote_identifiers: bool,
pub file_name: &'a str,
}
impl Default for Options<'_> {
fn default() -> Self {
Options {
table: "",
format: "insert",
dialect: "standard",
batch_size: 100,
infer_types: true,
quote_identifiers: true,
file_name: "import.csv",
}
}
}
pub struct Result {
pub ok: bool,
pub sql: String,
pub rows: usize,
pub error: Option<String>,
}
pub fn csv_to_sql(csv: &str, opts: &Options) -> Result {
let fail = |msg: &str| Result { ok: false, sql: String::new(), rows: 0, error: Some(msg.into()) };
let text = csv.trim();
let rows = csv_to_rows(text);
if text.is_empty() || rows.len() < 2 {
return fail("No rows to import.");
}
let cols = sanitize_headers(&rows[0]);
let data = &rows[1..];
let ident_dialect = match opts.format {
"copy" => "postgres",
"load-data" => "mysql",
_ => opts.dialect,
};
let q = |name: &str| {
if !opts.quote_identifiers {
name.to_string()
} else if ident_dialect == "mysql" {
format!("`{}`", name)
} else {
format!("\"{}\"", name)
}
};
let tbl_ident = sanitize_ident(opts.table);
let table = q(if tbl_ident.is_empty() { "tbl" } else { &tbl_ident });
let col_list = cols.iter().map(|c| q(c)).collect::<Vec<_>>().join(", ");
if opts.format == "copy" || opts.format == "load-data" {
let mut payload = vec![cols.iter().map(|c| csv_escape(c)).collect::<Vec<_>>().join(",")];
for r in data {
let fields: Vec<String> = (0..cols.len())
.map(|ci| csv_escape(r.get(ci).map(String::as_str).unwrap_or("")))
.collect();
payload.push(fields.join(","));
}
if opts.format == "copy" {
let sql = format!(
"COPY {} ({}) FROM STDIN WITH (FORMAT csv, HEADER true);\n{}\n\\.",
table, col_list, payload.join("\n")
);
return Result { ok: true, sql, rows: data.len(), error: None };
}
let clean: String = opts
.file_name
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '_' || *c == '-' || *c == '/')
.collect();
let clean = if clean.is_empty() { "import.csv" } else { &clean };
let sql = format!(
"LOAD DATA LOCAL INFILE '{}'\nINTO TABLE {}\nFIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"'\nLINES TERMINATED BY '\\n'\nIGNORE 1 LINES;\n\n{}",
clean, table, payload.join("\n")
);
return Result { ok: true, sql, rows: data.len(), error: None };
}
let size = opts.batch_size.max(1);
let mut stmts: Vec<String> = Vec::new();
for chunk in data.chunks(size) {
let values: Vec<String> = chunk
.iter()
.map(|r| {
let vals: Vec<String> = (0..cols.len())
.map(|ci| field_literal(r.get(ci).map(String::as_str).unwrap_or(""), opts.dialect, opts.infer_types))
.collect();
format!(" ({})", vals.join(", "))
})
.collect();
stmts.push(format!("INSERT INTO {} ({}) VALUES\n{};", table, col_list, values.join(",\n")));
}
Result { ok: true, sql: stmts.join("\n"), rows: data.len(), error: None }
}
// Example:
// let r = csv_to_sql("id,name\n1,Ada\n2,", &Options { table: "users", ..Default::default() });
// assert!(r.sql.contains("(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 →