CSV to SQL Importer — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
//! csv-to-sql — pure CSV → SQL import generator. Zig 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. Caller owns result.sql (free with the
//! allocator).
const std = @import("std");
fn strContainsAny(s: []const u8, chars: []const u8) bool {
for (s) |c| {
for (chars) |x| if (c == x) return true;
}
return false;
}
/// RFC 4180 parser mirroring the TS loop: lenient quotes, CR dropped, a
/// trailing field without a newline still completes its row. Caller frees.
pub fn csvToRows(a: std.mem.Allocator, csv: []const u8) ![][]const []const u8 {
var rows = std.ArrayList([]const []const u8).init(a);
var field = std.ArrayList(u8).init(a);
var row = std.ArrayList([]const u8).init(a);
var in_q = false;
var i: usize = 0;
while (i < csv.len) : (i += 1) {
const ch = csv[i];
if (in_q) {
if (ch == '"') {
if (i + 1 < csv.len and csv[i + 1] == '"') {
try field.append('"');
i += 1;
} else {
in_q = false;
}
} else {
try field.append(ch);
}
} else if (ch == '"') {
in_q = true;
} else if (ch == ',') {
try row.append(try field.toOwnedSlice());
} else if (ch == '\n') {
try row.append(try field.toOwnedSlice());
try rows.append(try row.toOwnedSlice());
row = std.ArrayList([]const u8).init(a);
} else if (ch != '\r') {
try field.append(ch);
}
}
if (field.items.len > 0 or row.items.len > 0) {
try row.append(try field.toOwnedSlice());
try rows.append(try row.toOwnedSlice());
}
return rows.toOwnedSlice();
}
fn isIdentChar(c: u8) bool {
return (c >= 'A' and c <= 'Z') or (c >= 'a' and c <= 'z') or
(c >= '0' and c <= '9') or c == '_';
}
/// Verbatim numeric literal: -?(digits[.digits] | .digits)(e[+-]?digits)?
/// "5." is rejected (digits required after the dot) — matches the TS regex.
fn isNumeric(s: []const u8) bool {
var i: usize = 0;
const n = s.len;
if (i < n and s[i] == '-') i += 1;
const int_start = i;
while (i < n and s[i] >= '0' and s[i] <= '9') i += 1;
const had_int = i > int_start;
var had_frac = false;
var saw_dot = false;
if (i < n and s[i] == '.') {
saw_dot = true;
i += 1;
const fs = i;
while (i < n and s[i] >= '0' and s[i] <= '9') i += 1;
had_frac = i > fs;
}
if (saw_dot and !had_frac) return false;
if (!had_int and !had_frac) return false;
if (i < n and (s[i] == 'e' or s[i] == 'E')) {
i += 1;
if (i < n and (s[i] == '+' or s[i] == '-')) i += 1;
const es = i;
while (i < n and s[i] >= '0' and s[i] <= '9') i += 1;
if (i == es) return false;
}
return i == n;
}
fn escapeSqlString(a: std.mem.Allocator, s: []const u8, mysql: bool) ![]const u8 {
var out = std.ArrayList(u8).init(a);
for (s) |c| {
switch (c) {
'\'' => try out.appendSlice("''"),
'\\' => if (mysql) try out.appendSlice("\\\\") else try out.append(c),
0x00 => if (mysql) try out.appendSlice("\\0") else try out.append(c),
'\n' => if (mysql) try out.appendSlice("\\n") else try out.append(c),
'\r' => if (mysql) try out.appendSlice("\\r") else try out.append(c),
0x1a => if (mysql) try out.appendSlice("\\Z") else try out.append(c),
else => try out.append(c),
}
}
return out.toOwnedSlice();
}
fn fieldLiteral(a: std.mem.Allocator, v: []const u8, mysql: bool, infer_types: bool) ![]const u8 {
if (infer_types) {
if (v.len == 0) return "NULL";
if (isNumeric(v)) return v; // verbatim — no float round-trip
}
const esc = try escapeSqlString(a, v, mysql);
defer a.free(esc);
return std.fmt.allocPrint(a, "'{s}'", .{esc});
}
fn sanitizeIdent(a: std.mem.Allocator, name: []const u8) ![]const u8 {
const out = try a.alloc(u8, name.len);
for (name, 0..) |c, i| out[i] = if (isIdentChar(c)) c else '_';
return out;
}
const SanitizedHeaders = struct {
cols: [][]const u8,
/// Allocations for generated names (dupes, colN) the caller must free.
owned: [][]const u8,
};
fn sanitizeHeaders(a: std.mem.Allocator, headers: []const []const u8) !SanitizedHeaders {
var owned = std.ArrayList([]const u8).init(a);
const cols = try a.alloc([]const u8, headers.len);
for (headers, 0..) |h, i| {
const trimmed = std.mem.trim(u8, h, " \t");
const raw = try sanitizeIdent(a, trimmed);
defer a.free(raw);
var id: []const u8 = raw;
if (id.len == 0) {
const gen = try std.fmt.allocPrint(a, "col{d}", .{i + 1});
try owned.append(gen);
id = gen;
}
// de-duplicate: count earlier equals
var dupes: usize = 0;
for (cols[0..i]) |prev| {
if (std.mem.eql(u8, prev, id)) dupes += 1;
}
if (dupes > 0) {
const suff = try std.fmt.allocPrint(a, "{s}_{d}", .{ id, dupes + 1 });
try owned.append(suff);
id = suff;
}
cols[i] = try a.dupe(u8, id);
}
return .{ .cols = cols, .owned = try owned.toOwnedSlice() };
}
fn csvEscape(a: std.mem.Allocator, f: []const u8) ![]const u8 {
if (!strContainsAny(f, ",\"\n\r")) return a.dupe(u8, f);
var out = std.ArrayList(u8).init(a);
try out.append('"');
for (f) |c| {
if (c == '"') try out.append('"');
try out.append(c);
}
try out.append('"');
return out.toOwnedSlice();
}
pub const Format = enum { insert, copy, load_data };
pub const Dialect = enum { standard, mysql, postgres };
pub const Options = struct {
table: []const u8 = "",
format: Format = .insert,
dialect: Dialect = .standard,
batch_size: usize = 100,
infer_types: bool = true,
quote_identifiers: bool = true,
file_name: []const u8 = "import.csv",
};
pub const Result = struct {
ok: bool,
sql: []const u8,
rows: usize,
/// Named err — "error" is a Zig keyword.
err: ?[]const u8,
};
fn quoteIdent(a: std.mem.Allocator, name: []const u8, mysql: bool, quote_ids: bool) ![]const u8 {
if (!quote_ids) return a.dupe(u8, name);
if (mysql) return std.fmt.allocPrint(a, "`{s}`", .{name});
return std.fmt.allocPrint(a, "\"{s}\"", .{name});
}
pub fn csvToSql(a: std.mem.Allocator, csv: []const u8, opts: Options) !Result {
const text = std.mem.trim(u8, csv, " \t\n\r");
const rows = try csvToRows(a, text);
if (text.len == 0 or rows.len < 2) {
return .{ .ok = false, .sql = "", .rows = 0, .err = "No rows to import." };
}
const sh = try sanitizeHeaders(a, rows[0]);
const cols = sh.cols;
const data = rows[1..];
const mysql = opts.dialect == .mysql;
const ident_mysql = switch (opts.format) {
.load_data => true,
.copy => false,
.insert => mysql,
};
const tbl_raw = try sanitizeIdent(a, opts.table);
const tbl_name: []const u8 = if (tbl_raw.len == 0) "tbl" else tbl_raw;
const tbl = try quoteIdent(a, tbl_name, ident_mysql, opts.quote_identifiers);
var col_list = std.ArrayList(u8).init(a);
for (cols, 0..) |c, i| {
if (i > 0) try col_list.appendSlice(", ");
const q = try quoteIdent(a, c, ident_mysql, opts.quote_identifiers);
try col_list.appendSlice(q);
}
if (opts.format == .copy or opts.format == .load_data) {
var payload = std.ArrayList(u8).init(a);
for (cols, 0..) |c, i| {
if (i > 0) try payload.append(',');
const e = try csvEscape(a, c);
try payload.appendSlice(e);
}
for (data) |r| {
try payload.append('\n');
for (cols, 0..) |_, ci| {
if (ci > 0) try payload.append(',');
const v = if (ci < r.len) r[ci] else "";
const e = try csvEscape(a, v);
try payload.appendSlice(e);
}
}
if (opts.format == .copy) {
const sql = try std.fmt.allocPrint(a, "COPY {s} ({s}) FROM STDIN WITH (FORMAT csv, HEADER true);\n{s}\n\\.", .{ tbl, col_list.items, payload.items });
return .{ .ok = true, .sql = sql, .rows = data.len, .err = null };
}
var clean = std.ArrayList(u8).init(a);
for (opts.file_name) |c| {
if (isIdentChar(c) or c == '.' or c == '-' or c == '/') try clean.append(c);
}
const file: []const u8 = if (clean.items.len == 0) "import.csv" else clean.items;
const sql = try std.fmt.allocPrint(a, "LOAD DATA LOCAL INFILE '{s}'\nINTO TABLE {s}\nFIELDS TERMINATED BY ',' OPTIONALLY ENCLOSED BY '\"'\nLINES TERMINATED BY '\\n'\nIGNORE 1 LINES;\n\n{s}", .{ file, tbl, payload.items });
return .{ .ok = true, .sql = sql, .rows = data.len, .err = null };
}
const size = @max(1, opts.batch_size);
var stmts = std.ArrayList(u8).init(a);
var start: usize = 0;
while (start < data.len) : (start += size) {
const end = @min(data.len, start + size);
if (start > 0) try stmts.append('\n');
try stmts.appendSlice("INSERT INTO ");
try stmts.appendSlice(tbl);
try stmts.appendSlice(" (");
try stmts.appendSlice(col_list.items);
try stmts.appendSlice(") VALUES\n");
for (start..end, 0..) |ri, k| {
if (k > 0) try stmts.appendSlice(",\n");
try stmts.appendSlice(" (");
for (cols, 0..) |_, ci| {
if (ci > 0) try stmts.appendSlice(", ");
const v = if (ci < data[ri].len) data[ri][ci] else "";
const lit = try fieldLiteral(a, v, mysql, opts.infer_types);
try stmts.appendSlice(lit);
}
try stmts.append(')');
}
try stmts.append(';');
}
return .{ .ok = true, .sql = stmts.items, .rows = data.len, .err = null };
}
// Example:
// var gpa = std.heap.GeneralPurposeAllocator(.{}){};
// const r = try csvToSql(gpa.allocator(), "id,name\n1,Ada\n2,", .{ .table = "users" });
// if (r.ok) std.debug.print("{s}\n", .{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 →